From e8f03822849a37187d44b2e3a662ac59cb99fe71 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 30 Jun 2026 19:22:55 +0800 Subject: [PATCH 1/7] feat: add reference-scoped symbol navigation --- lua/opencode/commands/handlers/workflow.lua | 2 +- lua/opencode/ui/formatter.lua | 112 +++++-- lua/opencode/ui/highlight.lua | 2 + lua/opencode/ui/navigation.lua | 131 +++++++- lua/opencode/ui/reference_picker.lua | 93 +++--- lua/opencode/ui/symbol_snapshot.lua | 224 +++++++++++++ lua/opencode/ui/symbol_tokens.lua | 67 ++++ tests/data/symbol-reference-navigation.json | 66 ++++ tests/replay/renderer_spec.lua | 38 +++ tests/unit/formatter_spec.lua | 207 ++++++++++++ tests/unit/navigation_spec.lua | 254 +++++++++++++- tests/unit/reference_picker_spec.lua | 104 ++++-- tests/unit/symbol_snapshot_spec.lua | 346 ++++++++++++++++++++ tests/unit/symbol_tokens_spec.lua | 63 ++++ 14 files changed, 1613 insertions(+), 96 deletions(-) create mode 100644 lua/opencode/ui/symbol_snapshot.lua create mode 100644 lua/opencode/ui/symbol_tokens.lua create mode 100644 tests/data/symbol-reference-navigation.json create mode 100644 tests/unit/symbol_snapshot_spec.lua create mode 100644 tests/unit/symbol_tokens_spec.lua diff --git a/lua/opencode/commands/handlers/workflow.lua b/lua/opencode/commands/handlers/workflow.lua index f0961dab..576bdb85 100644 --- a/lua/opencode/commands/handlers/workflow.lua +++ b/lua/opencode/commands/handlers/workflow.lua @@ -296,7 +296,7 @@ function M.actions.clear_files() end function M.actions.jump_to_file() - require('opencode.ui.navigation').jump_to_target_at_cursor() + require('opencode.ui.navigation').jump_to_file_at_cursor() end function M.actions.jump_to_target_at_cursor() diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 1a20f821..c6a93f66 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -7,6 +7,7 @@ local config = require('opencode.config') local snapshot = require('opencode.snapshot') local mention = require('opencode.ui.mention') local permission_window = require('opencode.ui.permission_window') +local symbol_tokens = require('opencode.ui.symbol_tokens') local tool_formatters = require('opencode.ui.formatter.tools') local format_utils = require('opencode.ui.formatter.utils') @@ -601,43 +602,112 @@ function M._format_context_file(output, path) return output:add_line(string.format('[`%s`](%s)', path, path)) end ----@param output Output Output object to write to ----@param text string ----@param message_id string|nil Optional message ID for reference parsing -function M._format_assistant_message(output, text, message_id) - local reference_picker = require('opencode.ui.reference_picker') - local references = reference_picker.parse_references(text, message_id) +local function ranges_overlap(start_a, end_a, start_b, end_b) + return start_a <= end_b and end_a >= start_b +end - -- If no references, just add the text as-is - if #references == 0 then - output:add_lines(vim.split(text, '\n')) - return +local function in_ranges(ranges, start_pos, end_pos) + for _, range in ipairs(ranges) do + if ranges_overlap(start_pos, end_pos, range[1], range[2]) then + return true + end end + return false +end + +local function snapshot_has_token(symbol_snapshot, symbol_refs, token) + for _, variant in ipairs(symbol_snapshot.token_variants(token)) do + if symbol_snapshot.has_token(symbol_refs, variant) then + return true + end + end + return false +end - -- Sort references by match_start position (ascending) +-- Reference icons are inserted into the rendered text, so these ranges must be +-- measured after rendering. Symbol highlights use them only to stay off file +-- references; jump targets are recomputed by navigation at keypress time. +local function rendered_text_with_reference_ranges(text, references) table.sort(references, function(a, b) return a.match_start < b.match_start end) - -- Build a new text with icons inserted before each reference - local result = '' + local rendered = '' + local rendered_reference_ranges = {} local last_pos = 1 local ref_icon = icons.get('reference') for _, ref in ipairs(references) do - -- Add text before this reference - result = result .. text:sub(last_pos, ref.match_start - 1) - -- Add the icon and the reference - result = result .. ref_icon .. text:sub(ref.match_start, ref.match_end) + rendered = rendered .. text:sub(last_pos, ref.match_start - 1) + + local ref_text = text:sub(ref.match_start, ref.match_end) + local rendered_ref_start = #rendered + #ref_icon + 1 + rendered = rendered .. ref_icon .. ref_text + table.insert(rendered_reference_ranges, { rendered_ref_start, rendered_ref_start + #ref_text - 1 }) + last_pos = ref.match_end + 1 end - -- Add any remaining text after the last reference if last_pos <= #text then - result = result .. text:sub(last_pos) + rendered = rendered .. text:sub(last_pos) + end + + return rendered, rendered_reference_ranges +end + +local function add_symbol_reference_highlights(output, rendered, rendered_reference_ranges, symbol_refs, first_line_idx) + local symbol_snapshot = require('opencode.ui.symbol_snapshot') + local line_start = 1 + + for line_idx, line in ipairs(vim.split(rendered, '\n')) do + local scan_from = 1 + while scan_from <= #line do + local start_pos, end_pos, token = symbol_tokens.find(line, scan_from) + if not start_pos then + break + end + + local abs_start = line_start + start_pos - 1 + local abs_end = line_start + end_pos - 1 + + if + token + and not in_ranges(rendered_reference_ranges, abs_start, abs_end) + and snapshot_has_token(symbol_snapshot, symbol_refs, token) + then + output:add_extmark(first_line_idx + line_idx - 1, { + start_col = start_pos - 1, + end_col = end_pos, + hl_group = 'OpencodeSymbolReference', + priority = 900, + }) + end + + scan_from = end_pos + 1 + end + + line_start = line_start + #line + 1 end +end - output:add_lines(vim.split(result, '\n')) +---@param output Output Output object to write to +---@param text string +---@param message_id string|nil Optional message ID for reference parsing +function M._format_assistant_message(output, text, message_id) + local reference_picker = require('opencode.ui.reference_picker') + local symbol_snapshot = require('opencode.ui.symbol_snapshot') + local references = reference_picker.parse_references(text, message_id or text) + local rendered, rendered_reference_ranges = rendered_text_with_reference_ranges(text, references) + local first_line_idx = output:get_line_count() + + output:add_lines(vim.split(rendered, '\n')) + + -- Render-time symbol highlights are only visual hints. This intentionally + -- rebuilds from the current conversation refs instead of storing targets on + -- extmarks; navigation recomputes the snapshot before jumping. + local refs = reference_picker.collect_refs() + local symbol_refs = symbol_snapshot.collect(refs) + add_symbol_reference_highlights(output, rendered, rendered_reference_ranges, symbol_refs, first_line_idx) end ---@param output Output Output object to write to @@ -754,7 +824,7 @@ function M.format_part(part, message, is_last_part, get_child_parts) end elseif role == 'assistant' then if part.type == 'text' and part.text then - M._format_assistant_message(output, vim.trim(part.text), part.messageID) + M._format_assistant_message(output, vim.trim(part.text), part.id or part.messageID) content_added = true elseif part.type == 'reasoning' then M._format_reasoning(output, part) diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index b92269cc..0ca6d8d5 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -42,6 +42,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#1976D2', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSymbolReference', { link = 'Identifier', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDialogOptionHover', { bg = '#E3F2FD', fg = '#1976D2', default = true }) @@ -90,6 +91,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#7AA2F7', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeSymbolReference', { link = 'Identifier', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDialogOptionHover', { bg = '#2B3A5A', fg = '#61AFEF', default = true }) diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index fe8e6894..b2ab1ff3 100644 --- a/lua/opencode/ui/navigation.lua +++ b/lua/opencode/ui/navigation.lua @@ -4,6 +4,7 @@ local state = require('opencode.state') local config = require('opencode.config') local renderer = require('opencode.ui.renderer') local output_window = require('opencode.ui.output_window') +local symbol_tokens = require('opencode.ui.symbol_tokens') function M.goto_message_by_id(message_id) require('opencode.ui.ui').focus_output() @@ -180,7 +181,11 @@ local function file_target_at_col(line, col) add_file_candidate(candidates, line, '()([%w_%-]+%.%w+:?%d*:?%d*)()') for _, candidate in ipairs(candidates) do - if candidate.target and contains_col(candidate.start_pos, candidate.end_pos, col) and resolve_path(candidate.target.path) then + if + candidate.target + and contains_col(candidate.start_pos, candidate.end_pos, col) + and resolve_path(candidate.target.path) + then return candidate.target end end @@ -196,6 +201,28 @@ local function first_file_target(line) end end +local function symbol_token_at_col(line, col) + return symbol_tokens.at_col(line, col) +end + +local function cursor_symbol_token() + local windows = state.windows or {} + local win = windows.output_win + local buf = windows.output_buf + + if not win or not buf or not vim.api.nvim_win_is_valid(win) then + return nil + end + + local cursor = vim.api.nvim_win_get_cursor(win) + local line = vim.api.nvim_buf_get_lines(buf, cursor[1] - 1, cursor[1], false)[1] + if not line then + return nil + end + + return symbol_token_at_col(line, cursor[2]) +end + local function diff_line_number(buf, line_num) local ns = output_window.namespace local extmarks = vim.api.nvim_buf_get_extmarks(buf, ns, { line_num - 1, 0 }, { line_num - 1, -1 }, { details = true }) @@ -216,7 +243,7 @@ local function diff_line_number(buf, line_num) end ---Resolve file and line number at cursor position in the output buffer. ----@return { path: string, line: number? }? +---@return { path: string, line: number?, col: number? }? function M.resolve_file_at_cursor() local windows = state.windows or {} local win = windows.output_win @@ -269,8 +296,12 @@ end ---@param path string local function open_silent(path) local escaped = vim.fn.fnameescape(path) - if not pcall(vim.cmd, 'buffer ' .. escaped) then - pcall(vim.cmd, 'edit ' .. escaped) + if not pcall(function() + vim.cmd('buffer ' .. escaped) + end) then + pcall(function() + vim.cmd('edit ' .. escaped) + end) end end @@ -326,16 +357,102 @@ function M.resolve_target_at_cursor() return M.resolve_file_at_cursor() end +local function target_key(target) + return table.concat({ target.path or '', target.line or 0, target.col or 0 }, ':') +end + +local function symbol_targets_for_token(token) + local reference_picker = require('opencode.ui.reference_picker') + local symbol_snapshot = require('opencode.ui.symbol_snapshot') + local refs = reference_picker.collect_refs() + local snapshot = symbol_snapshot.collect(refs) + local targets = {} + local seen = {} + + for _, variant in ipairs(symbol_snapshot.token_variants(token)) do + for _, target in ipairs(symbol_snapshot.targets_for_token(snapshot, variant)) do + local key = target_key(target) + if not seen[key] then + seen[key] = true + table.insert(targets, target) + end + end + end + + return targets +end + +local function format_symbol_target(target, width) + local location = target.path + if target.line then + location = location .. ':' .. target.line + if target.col then + location = location .. ':' .. target.col + end + end + local kind = target.kind and (' [' .. target.kind .. ']') or '' + return require('opencode.ui.base_picker').create_time_picker_item( + target.token .. kind .. ' ' .. location, + nil, + nil, + width + ) +end + +local function pick_symbol_target(token, targets) + return require('opencode.ui.base_picker').pick({ + items = targets, + format_fn = format_symbol_target, + actions = {}, + callback = function(selected) + if selected then + M.navigate_to_location(selected.path, selected.line, selected.col) + end + end, + title = 'Symbol References (' .. #targets .. ')', + width = config.ui.picker_width, + preview = 'file', + layout_opts = config.ui.picker, + }) +end + +local function jump_to_symbol_at_cursor() + local token = cursor_symbol_token() + if not token then + return + end + + local targets = symbol_targets_for_token(token) + if #targets == 0 then + vim.notify('No symbol target found: ' .. token, vim.log.levels.INFO) + return + end + + if #targets == 1 then + local target = targets[1] + M.navigate_to_location(target.path, target.line, target.col) + return + end + + pick_symbol_target(token, targets) +end + function M.jump_to_target_at_cursor() local resolved = M.resolve_target_at_cursor() - if not resolved then + if resolved then + M.navigate_to_location(resolved.path, resolved.line, resolved.col) return end - M.navigate_to_location(resolved.path, resolved.line, resolved.col) + + jump_to_symbol_at_cursor() end function M.jump_to_file_at_cursor() - M.jump_to_target_at_cursor() + local resolved = M.resolve_file_at_cursor() + if not resolved then + return + end + M.navigate_to_location(resolved.path, resolved.line, resolved.col) end return M diff --git a/lua/opencode/ui/reference_picker.lua b/lua/opencode/ui/reference_picker.lua index 46d71b63..167e0485 100644 --- a/lua/opencode/ui/reference_picker.lua +++ b/lua/opencode/ui/reference_picker.lua @@ -74,27 +74,7 @@ local function picker_ref_key(path, line) return make_absolute_path(path) .. ':' .. (line or 0) end ----@param text string ----@param message_id string ----@return CodeReference[] -function M.parse_references(text, message_id) - local c = cache[message_id] - if not c then - c = { - parsed_upto = 0, - refs = {}, - ranges = {}, - seen_paths = {}, - } - cache[message_id] = c - end - - local len = #text - if len <= c.parsed_upto then - return c.refs - end - - local scan_from = math.max(1, c.parsed_upto - OVERLAP + 1) +local function parse_references_into(text, c, scan_from) local chunk = text:sub(scan_from) local abs_offset = scan_from - 1 @@ -126,6 +106,40 @@ function M.parse_references(text, message_id) pos = me + 1 end end +end + +local function parse_references_uncached(text) + local c = { + refs = {}, + ranges = {}, + seen_paths = {}, + } + parse_references_into(text, c, 1) + return c.refs +end + +---@param text string +---@param message_id string +---@return CodeReference[] +function M.parse_references(text, message_id) + local c = cache[message_id] + if not c then + c = { + parsed_upto = 0, + refs = {}, + ranges = {}, + seen_paths = {}, + } + cache[message_id] = c + end + + local len = #text + if len <= c.parsed_upto then + return c.refs + end + + local scan_from = math.max(1, c.parsed_upto - OVERLAP + 1) + parse_references_into(text, c, scan_from) c.parsed_upto = len return c.refs @@ -152,7 +166,7 @@ local function format_reference_item(ref, width) return base_picker.create_time_picker_item(icon .. ' ' .. location, nil, nil, width) end -local function collect_picker_refs() +function M.collect_refs() if not state.messages then return {} end @@ -160,33 +174,28 @@ local function collect_picker_refs() local seen = {} local refs = {} + local function add_ref(ref) + local key = picker_ref_key(ref.file_path, ref.line) + if not seen[key] then + seen[key] = true + table.insert(refs, ref) + end + end + for i = #state.messages, 1, -1 do local msg = state.messages[i] if msg.info and msg.info.role == 'assistant' then - local message_id = msg.info.id - - local c = cache[message_id] - if c then - for _, ref in ipairs(c.refs) do - local key = picker_ref_key(ref.file_path, ref.line) - if not seen[key] then - seen[key] = true - table.insert(refs, ref) - end - end - end - if msg.parts then for _, part in ipairs(msg.parts) do - if part.type == 'tool' then + if part.type == 'text' and part.text then + for _, ref in ipairs(parse_references_uncached(part.text)) do + add_ref(ref) + end + elseif part.type == 'tool' then local file_path = vim.tbl_get(part, 'state', 'input', 'filePath') if file_path and vim.fn.filereadable(file_path) == 1 then local rel = vim.fn.fnamemodify(file_path, ':~:.') - local key = picker_ref_key(rel, nil) - if not seen[key] then - seen[key] = true - table.insert(refs, make_ref(rel, '', '', 0, 0)) - end + add_ref(make_ref(rel, '', '', 0, 0)) end end end @@ -198,7 +207,7 @@ local function collect_picker_refs() end function M.pick() - local refs = collect_picker_refs() + local refs = M.collect_refs() if #refs == 0 then vim.notify('No code references found in the conversation', vim.log.levels.INFO) return diff --git a/lua/opencode/ui/symbol_snapshot.lua b/lua/opencode/ui/symbol_snapshot.lua new file mode 100644 index 00000000..300ee977 --- /dev/null +++ b/lua/opencode/ui/symbol_snapshot.lua @@ -0,0 +1,224 @@ +local M = {} + +-- A snapshot is a pull-time view over the files referenced by the current +-- conversation. It has no lifecycle, cache, or edit subscriptions; render and +-- keypress paths collect a fresh snapshot when they need one. +local MIN_DEFINITION_TOKEN_LENGTH = 2 + +local function absolute_path(path) + if path:sub(1, 1) == '/' then + return path + end + return vim.fn.getcwd() .. '/' .. path +end + +local function definition_token(token) + return type(token) == 'string' + and #token >= MIN_DEFINITION_TOKEN_LENGTH + and not token:match('^%d+$') + and not token:match('%s') +end + +local function current_source_root(path, lang) + local source + local parser + local bufnr = vim.fn.bufnr and vim.fn.bufnr(path) or -1 + + if bufnr and bufnr > 0 and vim.api.nvim_buf_is_loaded and vim.api.nvim_buf_is_loaded(bufnr) then + source = bufnr + local parser_ok, buffer_parser = pcall(function() + if vim.treesitter and vim.treesitter.get_parser then + return vim.treesitter.get_parser(bufnr, lang) + end + end) + if parser_ok then + parser = buffer_parser + end + else + local read_ok, lines = pcall(vim.fn.readfile, path) + if read_ok and type(lines) == 'table' then + local content = table.concat(lines, '\n') + source = content + local parser_ok, string_parser = pcall(function() + if vim.treesitter and vim.treesitter.get_string_parser then + return vim.treesitter.get_string_parser(content, lang) + end + end) + if parser_ok then + parser = string_parser + end + end + end + + if not parser then + return nil, nil + end + + local parse_ok, trees = pcall(function() + return parser:parse() + end) + local tree = parse_ok and trees and trees[1] or nil + local root_ok, root = pcall(function() + return tree and tree:root() or nil + end) + if not root_ok then + return nil, nil + end + + return source, root +end + +function M.token_variants(token) + if type(token) ~= 'string' then + return {} + end + + local variants = {} + local seen = {} + + local function add_variant(value) + if value ~= '' and not seen[value] then + seen[value] = true + table.insert(variants, value) + end + end + + add_variant(token) + + local index = 1 + while index <= #token do + local dot_start, dot_end = token:find('%.', index) + local colon_start, colon_end = token:find('::', index, true) + local lua_colon_start, lua_colon_end = token:find(':', index, true) + if lua_colon_start and token:sub(lua_colon_start, lua_colon_start + 1) == '::' then + lua_colon_start, lua_colon_end = nil, nil + end + + local delimiter_start, delimiter_end = dot_start, dot_end + if colon_start and (not delimiter_start or colon_start < delimiter_start) then + delimiter_start, delimiter_end = colon_start, colon_end + end + if lua_colon_start and (not delimiter_start or lua_colon_start < delimiter_start) then + delimiter_start, delimiter_end = lua_colon_start, lua_colon_end + end + if not delimiter_start then + break + end + + add_variant(token:sub(delimiter_end + 1)) + index = delimiter_end + 1 + end + + return variants +end + +local function collect_path(snapshot, path) + local filetype = vim.filetype and vim.filetype.match and vim.filetype.match({ filename = path }) or nil + if not filetype then + return + end + + local lang = filetype + local lang_ok, parser_lang = pcall(function() + if vim.treesitter and vim.treesitter.language and vim.treesitter.language.get_lang then + return vim.treesitter.language.get_lang(filetype) + end + end) + if lang_ok and parser_lang then + lang = parser_lang + end + + local query_ok, query = pcall(function() + if vim.treesitter and vim.treesitter.query and vim.treesitter.query.get then + return vim.treesitter.query.get(lang, 'locals') + end + end) + if not query_ok or not query then + return + end + + local source, root = current_source_root(path, lang) + if not (source and root) then + return + end + + local iter_ok, iter, iter_state, iter_initial = pcall(function() + return query:iter_captures(root, source, 0, -1) + end) + if not iter_ok or not iter then + return + end + + for capture_id, node in iter, iter_state, iter_initial do + local capture = query.captures and query.captures[capture_id] + local kind = capture and capture:match('^local%.definition%.(.+)$') + local text_ok, token = pcall(function() + return vim.treesitter.get_node_text(node, source) + end) + if kind and kind ~= 'associated' and text_ok and definition_token(token) then + local row, col = node:range() + local targets = snapshot.by_token[token] + if not targets then + targets = {} + snapshot.by_token[token] = targets + end + table.insert(targets, { + token = token, + path = path, + line = row + 1, + col = col + 1, + kind = kind, + }) + end + end +end + +function M.collect(refs) + local snapshot = { by_token = {} } + local seen_paths = {} + local paths = {} + + for _, ref in ipairs(refs or {}) do + if ref.file_path then + local path = absolute_path(ref.file_path) + if not seen_paths[path] and vim.fn.filereadable(path) == 1 then + seen_paths[path] = true + table.insert(paths, path) + end + end + end + + for _, path in ipairs(paths) do + collect_path(snapshot, path) + end + + return snapshot +end + +function M.has_token(snapshot, token) + if not (snapshot and snapshot.by_token) then + return false + end + + local targets = snapshot.by_token[token] + return targets ~= nil and #targets > 0 +end + +function M.targets_for_token(snapshot, token) + if not (snapshot and snapshot.by_token) then + return {} + end + + local targets = snapshot.by_token[token] + if not targets then + return {} + end + + local copy = {} + for _, target in ipairs(targets) do + table.insert(copy, target) + end + return copy +end + +return M diff --git a/lua/opencode/ui/symbol_tokens.lua b/lua/opencode/ui/symbol_tokens.lua new file mode 100644 index 00000000..28a89f3d --- /dev/null +++ b/lua/opencode/ui/symbol_tokens.lua @@ -0,0 +1,67 @@ +local M = {} + +local function is_candidate(token) + return token:find('[%a_]') ~= nil +end + +local function is_path_segment(line, start_pos, end_pos) + -- Path fragments belong to file/reference handling. Emitting their segments as + -- symbols would make directories like `tests/data` look jumpable. + local before = start_pos > 1 and line:sub(start_pos - 1, start_pos - 1) or '' + local after = end_pos < #line and line:sub(end_pos + 1, end_pos + 1) or '' + return before:match('[/\\%-]') ~= nil + or after:match('[/\\%-]') ~= nil + or (before == '.' and (start_pos == 2 or not line:sub(start_pos - 2, start_pos - 2):match('[%w_]'))) +end + +function M.find(line, scan_from) + local start_pos, end_pos = line:find('[%w_][%w_]*', scan_from) + if not start_pos then + return nil + end + + -- Qualified spans may contain `.`, `::`, or Lua's method `:`. + -- A lone `:` followed by prose punctuation stays outside the symbol. + while end_pos < #line do + local delimiter = line:sub(end_pos + 1, end_pos + 2) + local delimiter_len + if delimiter == '::' then + delimiter_len = 2 + elseif line:sub(end_pos + 1, end_pos + 1) == ':' then + delimiter_len = 1 + elseif line:sub(end_pos + 1, end_pos + 1) == '.' then + delimiter_len = 1 + else + break + end + + local tail_start = end_pos + delimiter_len + 1 + local tail_start_pos, tail_end_pos = line:find('[%w_][%w_]*', tail_start) + if tail_start_pos ~= tail_start then + break + end + end_pos = tail_end_pos + end + + local token = line:sub(start_pos, end_pos) + if not is_candidate(token) or is_path_segment(line, start_pos, end_pos) then + return start_pos, end_pos, nil + end + return start_pos, end_pos, token +end + +function M.at_col(line, col) + local scan_from = 1 + while scan_from <= #line do + local start_pos, end_pos, token = M.find(line, scan_from) + if not start_pos then + return nil + end + if col >= start_pos - 1 and col <= end_pos - 1 then + return token + end + scan_from = end_pos + 1 + end +end + +return M diff --git a/tests/data/symbol-reference-navigation.json b/tests/data/symbol-reference-navigation.json new file mode 100644 index 00000000..735b1ef5 --- /dev/null +++ b/tests/data/symbol-reference-navigation.json @@ -0,0 +1,66 @@ +[ + { + "type": "session.updated", + "properties": { + "info": { + "id": "ses_symbol_reference_navigation", + "directory": "/mock/project/path", + "time": { + "created": 1760000000000, + "updated": 1760000000000 + }, + "version": "0.15.0", + "title": "Symbol Reference Navigation Test", + "projectID": "test-symbol-reference-navigation" + } + } + }, + { + "type": "message.updated", + "properties": { + "info": { + "id": "msg_symbol_reference_navigation", + "sessionID": "ses_symbol_reference_navigation", + "time": { + "created": 1760000001000 + }, + "role": "assistant" + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_symbol_reference_navigation_tool", + "messageID": "msg_symbol_reference_navigation", + "sessionID": "ses_symbol_reference_navigation", + "type": "tool", + "tool": "read", + "state": { + "status": "completed", + "input": { + "filePath": "lua/opencode/ui/symbol_snapshot.lua" + }, + "output": "lua/opencode/ui/symbol_snapshot.lua\nfunction M.collect(refs)", + "time": { + "start": 1760000001000, + "end": 1760000001001 + } + } + } + } + }, + { + "type": "message.part.updated", + "properties": { + "part": { + "id": "prt_symbol_reference_navigation_text", + "messageID": "msg_symbol_reference_navigation", + "sessionID": "ses_symbol_reference_navigation", + "type": "text", + "text": "collect should be highlighted from the referenced file." + } + } + } +] diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index 0bdd0fa5..a2926078 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -402,6 +402,44 @@ describe('renderer unit tests', function() pcall(vim.api.nvim_buf_delete, code_buf, { force = true }) end) + it('renders reference-scoped symbol highlights through full session replay', function() + local renderer = require('opencode.ui.renderer') + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local events = helpers.load_test_data('tests/data/symbol-reference-navigation.json') + local referenced_file = 'lua/opencode/ui/symbol_snapshot.lua' + + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function(refs) + assert.are.equal(1, #refs) + assert.are.equal(referenced_file, refs[1].file_path) + return { by_token = { collect = true } } + end, + token_variants = function(token) + return { token } + end, + has_token = function(_, token) + return token == 'collect' + end, + } + + helpers.replay_setup() + state.session.set_active(helpers.get_session_from_events(events, true)) + renderer._render_full_session_data(helpers.load_session_from_events(events)) + + local actual = helpers.capture_output(state.windows.output_buf, output_window.namespace) + local symbol_mark + for _, mark in ipairs(actual.extmarks) do + if mark[4] and mark[4].hl_group == 'OpencodeSymbolReference' then + symbol_mark = mark + break + end + end + + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.is_not_nil(symbol_mark) + end) + it('limits rendered messages and inserts a hidden-messages notice', function() local renderer = require('opencode.ui.renderer') diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index 5a7620e6..3756b563 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -307,6 +307,213 @@ describe('formatter', function() assert.are.equal('OpencodeDiffAddGutter', add_mark.virt_text[1][2]) end) + it('highlights assistant symbols from the reference-scoped snapshot without current text refs', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + + package.loaded['opencode.ui.reference_picker'] = { + parse_references = function() + return {} + end, + collect_refs = function() + return { { file_path = 'src/main.lua' } } + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function(refs) + assert.are.same({ { file_path = 'src/main.lua' } }, refs) + return { by_token = { foo = true } } + end, + token_variants = function(token) + return { token } + end, + has_token = function(_, token) + return token == 'foo' + end, + } + + local output = Output.new() + formatter._format_assistant_message(output, 'foo bar', 'msg_symbols') + + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.are.equal('foo bar', output.lines[1]) + assert.are.equal('OpencodeSymbolReference', output.extmarks[0][1].hl_group) + assert.are.equal(0, output.extmarks[0][1].start_col) + assert.are.equal(3, output.extmarks[0][1].end_col) + assert.is_nil(output.extmarks[0][1].target) + end) + + it('does not let symbol highlights overwrite rendered file reference spans', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local text = 'See `src/foo.lua` foo' + local ref_start, ref_end = text:find('`src/foo.lua`', 1, true) + + package.loaded['opencode.ui.reference_picker'] = { + parse_references = function() + return { + { + file_path = 'src/foo.lua', + match_start = ref_start, + match_end = ref_end, + }, + } + end, + collect_refs = function() + return { { file_path = 'src/foo.lua' } } + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + return { by_token = {} } + end, + token_variants = function(token) + return { token } + end, + has_token = function(_, token) + return token == 'src/foo.lua' or token == 'foo' + end, + } + + local output = Output.new() + formatter._format_assistant_message(output, text, 'msg_file_ref') + + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + local symbol_mark + for _, mark in ipairs(output.extmarks[0]) do + if mark.hl_group == 'OpencodeSymbolReference' then + symbol_mark = mark + end + end + local trailing_foo_start = output.lines[1]:find('foo$', 1, false) + assert.are.equal(1, #output.extmarks[0]) + assert.is_not_nil(symbol_mark) + assert.are.equal(trailing_foo_start - 1, symbol_mark.start_col) + assert.are.equal(trailing_foo_start + 2, symbol_mark.end_col) + end) + + it('does not highlight symbol-looking segments inside paths', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + + package.loaded['opencode.ui.reference_picker'] = { + parse_references = function() + return {} + end, + collect_refs = function() + return { { file_path = 'lua/opencode/ui/symbol_snapshot.lua' } } + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + return { by_token = {} } + end, + token_variants = function(token) + return { token } + end, + has_token = function(_, token) + return token == 'data' or token == 'navigation' or token == 'cache' + end, + } + + local output = Output.new() + formatter._format_assistant_message( + output, + 'See tests/data/symbol-reference-navigation.json and .cache/', + 'msg_path' + ) + + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.is_nil(output.extmarks[0]) + end) + + it('uses part-level reference parse keys for assistant text parts', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local reference_picker = require('opencode.ui.reference_picker') + reference_picker.clear_all() + + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + return { by_token = {} } + end, + token_variants = function(token) + return { token } + end, + has_token = function() + return false + end, + } + + local message = { + info = { id = 'msg_same', role = 'assistant', sessionID = 'ses_1' }, + parts = {}, + } + local first = formatter.format_part({ + id = 'part_a', + type = 'text', + text = 'See `a.lua`', + messageID = 'msg_same', + sessionID = 'ses_1', + }, message, false) + local second = formatter.format_part({ + id = 'part_b', + type = 'text', + text = 'See `b.lua`', + messageID = 'msg_same', + sessionID = 'ses_1', + }, message, true) + + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + reference_picker.clear_all() + + assert.is_truthy(first.lines[1]:find('a.lua', 1, true)) + assert.is_nil(first.lines[1]:find('b.lua', 1, true)) + assert.is_truthy(second.lines[1]:find('b.lua', 1, true)) + assert.is_nil(second.lines[1]:find('a.lua', 1, true)) + end) + + it('highlights a symbol before trailing prose colon', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + + package.loaded['opencode.ui.reference_picker'] = { + parse_references = function() + return {} + end, + collect_refs = function() + return { { file_path = 'src/main.lua' } } + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + return { by_token = {} } + end, + token_variants = function(token) + return { token } + end, + has_token = function(_, token) + return token == 'foo' + end, + } + + local output = Output.new() + formatter._format_assistant_message(output, 'foo: call this', 'msg_colon') + + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.are.equal('foo: call this', output.lines[1]) + assert.are.equal(1, #output.extmarks[0]) + assert.are.equal(0, output.extmarks[0][1].start_col) + assert.are.equal(3, output.extmarks[0][1].end_col) + end) + it('formats grep tools when streamed input contains vim.NIL placeholders', function() local message = { info = { diff --git a/tests/unit/navigation_spec.lua b/tests/unit/navigation_spec.lua index ec7bfd70..e5e3033a 100644 --- a/tests/unit/navigation_spec.lua +++ b/tests/unit/navigation_spec.lua @@ -153,7 +153,7 @@ describe('output token navigation', function() assert.is_nil(navigation.resolve_target_at_cursor()) end) - it('keeps window and cursor unchanged on missing path or plain text', function() + it('keeps gf file-only and silent on missing path or plain text', function() local notify_stub = stub(vim, 'notify') local load_stub = stub(renderer, 'load_all_messages') vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { '`missing/not_here.lua`', 'plain text' }) @@ -161,7 +161,7 @@ describe('output token navigation', function() local before_win = vim.api.nvim_get_current_win() local before_cursor = vim.api.nvim_win_get_cursor(output_win) - navigation.jump_to_target_at_cursor() + navigation.jump_to_file_at_cursor() assert.equals(before_win, vim.api.nvim_get_current_win()) assert.same(before_cursor, vim.api.nvim_win_get_cursor(output_win)) @@ -169,7 +169,7 @@ describe('output token navigation', function() assert.stub(load_stub).was_not_called() vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) - navigation.jump_to_target_at_cursor() + navigation.jump_to_file_at_cursor() assert.equals(before_win, vim.api.nvim_get_current_win()) assert.stub(notify_stub).was_not_called() assert.stub(load_stub).was_not_called() @@ -178,6 +178,228 @@ describe('output token navigation', function() load_stub:revert() end) + it('uses symbol fallback only after file resolution misses', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated + + package.loaded['opencode.ui.reference_picker'] = { + collect_refs = function() + return { { file_path = 'src/main.lua' } } + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function(refs) + assert.same({ { file_path = 'src/main.lua' } }, refs) + return { by_token = {} } + end, + token_variants = function(token) + assert.equal('M.actions.jump_to_file', token) + return { 'M.actions.jump_to_file', 'actions.jump_to_file', 'jump_to_file' } + end, + targets_for_token = function(_, token) + if token == 'jump_to_file' then + return { { token = 'jump_to_file', path = existing_path, line = 12, col = 3 } } + end + return {} + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated = { path = path, line = line, col = col } + end + + local line = 'call M.actions.jump_to_file now' + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + set_cursor_on(output_win, 1, line, 'M.actions.jump_to_file') + + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.same({ path = existing_path, line = 12, col = 3 }, navigated) + end) + + it('offers multiple symbol fallback targets through the base picker', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_base_picker = package.loaded['opencode.ui.base_picker'] + local original_navigate_to_location = navigation.navigate_to_location + local picked_opts + local navigated + local targets = { + { token = 'foo', path = existing_path, line = 1, col = 1, kind = 'function' }, + { token = 'foo', path = existing_path, line = 2, col = 1 }, + } + + package.loaded['opencode.ui.reference_picker'] = { + collect_refs = function() + return { { file_path = existing_path } } + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + return { by_token = {} } + end, + token_variants = function(token) + return { token } + end, + targets_for_token = function() + return targets + end, + } + package.loaded['opencode.ui.base_picker'] = { + create_time_picker_item = function(text) + return { text = text } + end, + pick = function(opts) + picked_opts = opts + opts.callback(opts.items[2]) + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated = { path = path, line = line, col = col } + end + + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + package.loaded['opencode.ui.base_picker'] = original_base_picker + + assert.same(targets, picked_opts.items) + assert.equal('file', picked_opts.preview) + assert.equal('Symbol References (2)', picked_opts.title) + assert.equal( + 'foo [function] ' .. existing_path .. ':1:1', + picked_opts.format_fn(targets[1], 80):to_string():match('^%s*(.-)%s*$') + ) + assert.same({ path = existing_path, line = 2, col = 1 }, navigated) + end) + + it('uses the symbol before a trailing prose colon', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated + + package.loaded['opencode.ui.reference_picker'] = { + collect_refs = function() + return { { file_path = existing_path } } + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + return { by_token = {} } + end, + token_variants = function(token) + assert.equal('foo', token) + return { token } + end, + targets_for_token = function(_, token) + if token == 'foo' then + return { { token = 'foo', path = existing_path, line = 3, col = 1 } } + end + return {} + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated = { path = path, line = line, col = col } + end + + local line = 'foo: call this' + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + set_cursor_on(output_win, 1, line, 'foo') + + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.same({ path = existing_path, line = 3, col = 1 }, navigated) + end) + + it('does not treat a prose colon as part of the symbol token', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local notify_stub = stub(vim, 'notify') + + package.loaded['opencode.ui.reference_picker'] = { + collect_refs = function() + return { { file_path = existing_path } } + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + return { by_token = {} } + end, + token_variants = function(token) + error('symbol fallback should not run for cursor on prose colon: ' .. token) + end, + targets_for_token = function() + return {} + end, + } + + local line = 'Note: plain text' + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + set_cursor_on(output_win, 1, line, ':') + + navigation.jump_to_target_at_cursor() + + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.stub(notify_stub).was_not_called() + notify_stub:revert() + end) + + it('notifies on symbol fallback miss without moving the cursor or window', function() + local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local notify_stub = stub(vim, 'notify') + + package.loaded['opencode.ui.reference_picker'] = { + collect_refs = function() + return {} + end, + } + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + return { by_token = {} } + end, + token_variants = function(token) + return { token } + end, + targets_for_token = function() + return {} + end, + } + + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'plain' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + local before_win = vim.api.nvim_get_current_win() + local before_cursor = vim.api.nvim_win_get_cursor(output_win) + + navigation.jump_to_target_at_cursor() + + package.loaded['opencode.ui.reference_picker'] = original_reference_picker + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.equals(before_win, vim.api.nvim_get_current_win()) + assert.same(before_cursor, vim.api.nvim_win_get_cursor(output_win)) + assert.stub(notify_stub).was_called_with('No symbol target found: plain', vim.log.levels.INFO) + + notify_stub:revert() + end) + it('opens explicit locations with 1-based col converted and clamped', function() navigation.navigate_to_location(existing_path, 9999, 9999) @@ -189,6 +411,32 @@ describe('output token navigation', function() assert.equals(math.max(#line - 1, 0), cursor[2]) end) + it('keeps file-first without reading symbol fallback state', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated + + package.loaded['opencode.ui.symbol_snapshot'] = { + collect = function() + error('symbol fallback should not run when a file target exists') + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated = { path = path, line = line, col = col } + end + + local line = 'open ' .. existing_path .. ':7:2' + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + set_cursor_on(output_win, 1, line, existing_path) + + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.same({ path = existing_path, line = 7, col = 2 }, navigated) + end) + it('hides current-mode output before opening a target so output view can be restored', function() config.values.ui.position = 'current' config.values.ui.persist_state = true diff --git a/tests/unit/reference_picker_spec.lua b/tests/unit/reference_picker_spec.lua index 5b8df616..4dba33d8 100644 --- a/tests/unit/reference_picker_spec.lua +++ b/tests/unit/reference_picker_spec.lua @@ -415,17 +415,21 @@ describe('opencode.ui.reference_picker', function() end) end) - -- Helper: populate parse cache then expose items via pick() + -- Helper: populate state messages then expose items via pick() local function pick_items(messages_and_texts) -- messages_and_texts: list of { id, role, text, parts } local state_msgs = {} for _, m in ipairs(messages_and_texts) do + local parts = {} if m.text then - reference_picker.parse_references(m.text, m.id) + table.insert(parts, { type = 'text', id = m.id .. ':text', text = m.text }) + end + for _, part in ipairs(m.parts or {}) do + table.insert(parts, part) end table.insert(state_msgs, { info = { role = m.role or 'assistant', id = m.id }, - parts = m.parts, + parts = parts, }) end mock_state.messages = state_msgs @@ -439,6 +443,62 @@ describe('opencode.ui.reference_picker', function() return captured and captured.items or nil end + describe('collect_refs', function() + it('rebuilds text refs from assistant message parts when parse cache is empty', function() + mock_state.messages = { + { + info = { role = 'assistant', id = 'msg1' }, + parts = { + { type = 'text', id = 'part1', text = 'Check `src/main.lua:10`.' }, + }, + }, + } + + local refs = reference_picker.collect_refs() + + assert.equal(1, #refs) + assert.equal('src/main.lua', refs[1].file_path) + assert.equal(10, refs[1].line) + end) + + it('collects tool part file paths', function() + mock_state.messages = { + { + info = { role = 'assistant', id = 'msg1' }, + parts = { + { + type = 'tool', + state = { input = { filePath = '/test/project/src/file.lua' } }, + }, + }, + }, + } + + local refs = reference_picker.collect_refs() + + assert.equal(1, #refs) + assert.equal('src/file.lua', refs[1].file_path) + end) + + it('keeps separate text parts in the same assistant message', function() + mock_state.messages = { + { + info = { role = 'assistant', id = 'msg1' }, + parts = { + { type = 'text', id = 'part1', text = 'Check `src/one.lua`.' }, + { type = 'text', id = 'part2', text = 'Then `src/two.lua`.' }, + }, + }, + } + + local refs = reference_picker.collect_refs() + + assert.equal(2, #refs) + assert.equal('src/one.lua', refs[1].file_path) + assert.equal('src/two.lua', refs[2].file_path) + end) + end) + describe('pick', function() it('shows notification when no references found', function() local notify_calls = {} @@ -463,8 +523,14 @@ describe('opencode.ui.reference_picker', function() return {} end - reference_picker.parse_references('Check `src/main.lua:10`.', 'msg1') - mock_state.messages = { { info = { role = 'assistant', id = 'msg1' } } } + mock_state.messages = { + { + info = { role = 'assistant', id = 'msg1' }, + parts = { + { type = 'text', id = 'part1', text = 'Check `src/main.lua:10`.' }, + }, + }, + } reference_picker.pick() assert.equal(1, #pick_calls) @@ -476,7 +542,7 @@ describe('opencode.ui.reference_picker', function() assert.equal('file', pick_calls[1].preview) end) - it('collects references from cached assistant message text', function() + it('collects references from assistant message text parts', function() local items = pick_items({ { id = 'msg1', text = 'Check `src/main.lua:10` for details.' }, }) @@ -488,8 +554,14 @@ describe('opencode.ui.reference_picker', function() end) it('ignores user messages when collecting refs', function() - reference_picker.parse_references('Check `src/main.lua:10`.', 'msg1') - mock_state.messages = { { info = { role = 'user', id = 'msg1' } } } + mock_state.messages = { + { + info = { role = 'user', id = 'msg1' }, + parts = { + { type = 'text', id = 'part1', text = 'Check `src/main.lua:10`.' }, + }, + }, + } local notify_calls = {} local original_notify = vim.notify @@ -534,7 +606,6 @@ describe('opencode.ui.reference_picker', function() assert.is_not_nil(items) assert.equal(1, #items) - -- The kept ref came from the cache for msg2 assert.equal('src/main.lua', items[1].file_path) end) @@ -647,20 +718,9 @@ describe('opencode.ui.reference_picker', function() -- Simulate a messages state change handler() - -- Cache is now cleared; pick() finds no refs for msg1 - mock_state.messages = { { info = { role = 'assistant', id = 'msg1' } } } - - local notify_calls = {} - local original_notify = vim.notify - vim.notify = function(msg, level) - table.insert(notify_calls, { msg = msg, level = level }) - end + local refs = reference_picker.parse_references('No refs.', 'msg1') - reference_picker.pick() - - assert.equal(1, #notify_calls) - assert.equal('No code references found in the conversation', notify_calls[1].msg) - vim.notify = original_notify + assert.equal(0, #refs) end) end) end) diff --git a/tests/unit/symbol_snapshot_spec.lua b/tests/unit/symbol_snapshot_spec.lua new file mode 100644 index 00000000..d4b6364d --- /dev/null +++ b/tests/unit/symbol_snapshot_spec.lua @@ -0,0 +1,346 @@ +local assert = require('luassert') + +describe('opencode.ui.symbol_snapshot', function() + local symbol_snapshot + local original_fn + local original_api + local original_filetype + local original_treesitter + local original_notify + local files + local buffers + local captures_by_content + local query_available + local parser_available + local notify_calls + + local function fake_node(text, row, col) + return { + text = text, + range = function() + return row, col, row, col + #text + end, + } + end + + local function set_file(path, lines, captures) + files[path] = lines + captures_by_content[table.concat(lines, '\n')] = captures or {} + end + + before_each(function() + original_fn = vim.fn + original_api = vim.api + original_filetype = vim.filetype + original_treesitter = vim.treesitter + original_notify = vim.notify + files = {} + buffers = {} + captures_by_content = {} + query_available = true + parser_available = true + notify_calls = {} + + vim.fn = vim.tbl_extend('force', vim.fn or {}, { + getcwd = function() + return '/test/project' + end, + filereadable = function(path) + return files[path] and 1 or 0 + end, + readfile = function(path) + if not files[path] then + error('missing file') + end + return files[path] + end, + bufnr = function(path) + return buffers[path] and buffers[path].bufnr or -1 + end, + }) + + vim.api = vim.tbl_extend('force', vim.api or {}, { + nvim_buf_is_loaded = function(bufnr) + for _, buffer in pairs(buffers) do + if buffer.bufnr == bufnr then + return true + end + end + return false + end, + nvim_buf_get_lines = function(bufnr) + for _, buffer in pairs(buffers) do + if buffer.bufnr == bufnr then + return buffer.lines + end + end + return {} + end, + }) + + vim.filetype = { + match = function(opts) + if opts.filename:match('%.lua$') then + return 'lua' + end + end, + } + + vim.treesitter = { + language = { + get_lang = function(filetype) + return filetype + end, + }, + query = { + get = function(_, name) + if not query_available or name ~= 'locals' then + return nil + end + + return { + captures = { + 'local.definition.function', + 'local.definition.var', + 'local.reference', + 'local.definition.associated', + }, + iter_captures = function(_, _, source) + local content = source + if type(source) == 'number' then + for _, buffer in pairs(buffers) do + if buffer.bufnr == source then + content = table.concat(buffer.lines, '\n') + end + end + end + local captures = captures_by_content[content] or {} + local index = 0 + return function() + index = index + 1 + local capture = captures[index] + if capture then + return capture.id, capture.node + end + end + end, + } + end, + }, + get_string_parser = function(content) + if not parser_available then + error('parser unavailable') + end + return { + parse = function() + return { + { + root = function() + return { content = content } + end, + }, + } + end, + } + end, + get_parser = function(bufnr) + if not parser_available then + error('parser unavailable') + end + return { + parse = function() + return { + { + root = function() + return { bufnr = bufnr } + end, + }, + } + end, + } + end, + get_node_text = function(node) + return node.text + end, + } + + vim.notify = function(msg, level) + table.insert(notify_calls, { msg = msg, level = level }) + end + + package.loaded['opencode.ui.symbol_snapshot'] = nil + symbol_snapshot = require('opencode.ui.symbol_snapshot') + end) + + after_each(function() + vim.fn = original_fn + vim.api = original_api + vim.filetype = original_filetype + vim.treesitter = original_treesitter + vim.notify = original_notify + package.loaded['opencode.ui.symbol_snapshot'] = nil + end) + + it('exports only the frozen public API', function() + local keys = {} + for key in pairs(symbol_snapshot) do + table.insert(keys, key) + end + table.sort(keys) + + assert.same({ 'collect', 'has_token', 'targets_for_token', 'token_variants' }, keys) + end) + + it('collects definition tokens from referenced readable Lua files', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + { id = 3, node = fake_node('ignored', 0, 0) }, + }) + + local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local targets = symbol_snapshot.targets_for_token(snapshot, 'foo') + + assert.is_true(symbol_snapshot.has_token(snapshot, 'foo')) + assert.equal(1, #targets) + assert.equal('/test/project/src/main.lua', targets[1].path) + assert.equal(1, targets[1].line) + assert.equal(16, targets[1].col) + assert.equal('function', targets[1].kind) + end) + + it('does not include tokens from files absent from refs', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + set_file('/test/project/src/other.lua', { 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 0, 15) }, + }) + + local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + + assert.is_true(symbol_snapshot.has_token(snapshot, 'foo')) + assert.is_false(symbol_snapshot.has_token(snapshot, 'bar')) + end) + + it('reflects file changes on each collect call', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + local first = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + + set_file('/test/project/src/main.lua', { '', '', 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 2, 15) }, + }) + local second = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + + assert.is_true(symbol_snapshot.has_token(first, 'foo')) + assert.is_false(symbol_snapshot.has_token(second, 'foo')) + local targets = symbol_snapshot.targets_for_token(second, 'bar') + assert.equal(1, #targets) + assert.equal(3, targets[1].line) + end) + + it('uses loaded buffer content before disk content', function() + set_file('/test/project/src/main.lua', { 'local function disk_name() end' }, { + { id = 1, node = fake_node('disk_name', 0, 15) }, + }) + buffers['/test/project/src/main.lua'] = { + bufnr = 7, + lines = { 'local function buffer_name() end' }, + } + captures_by_content['local function buffer_name() end'] = { + { id = 1, node = fake_node('buffer_name', 0, 15) }, + } + + local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + + assert.is_true(symbol_snapshot.has_token(snapshot, 'buffer_name')) + assert.is_false(symbol_snapshot.has_token(snapshot, 'disk_name')) + end) + + it('filters empty, short, numeric, and whitespace definition tokens', function() + set_file('/test/project/src/main.lua', { 'symbols' }, { + { id = 1, node = fake_node('', 0, 0) }, + { id = 1, node = fake_node('x', 0, 0) }, + { id = 1, node = fake_node('123', 0, 0) }, + { id = 1, node = fake_node('two words', 0, 0) }, + { id = 1, node = fake_node('ok', 0, 0) }, + }) + + local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + + assert.is_false(symbol_snapshot.has_token(snapshot, 'x')) + assert.is_false(symbol_snapshot.has_token(snapshot, '123')) + assert.is_false(symbol_snapshot.has_token(snapshot, 'two words')) + assert.is_true(symbol_snapshot.has_token(snapshot, 'ok')) + end) + + it('skips Lua associated owner captures', function() + set_file('/test/project/src/client.lua', { 'function OpencodeApiClient:_call() end' }, { + { id = 4, node = fake_node('OpencodeApiClient', 0, 9) }, + { id = 1, node = fake_node('_call', 0, 27) }, + }) + + local snapshot = symbol_snapshot.collect({ { file_path = 'src/client.lua' } }) + + assert.is_false(symbol_snapshot.has_token(snapshot, 'OpencodeApiClient')) + assert.is_true(symbol_snapshot.has_token(snapshot, '_call')) + end) + + it('silently skips parser and query failures', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + + parser_available = false + local no_parser = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + parser_available = true + query_available = false + local no_query = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + + assert.is_false(symbol_snapshot.has_token(no_parser, 'foo')) + assert.is_false(symbol_snapshot.has_token(no_query, 'foo')) + assert.equal(0, #notify_calls) + end) + + it('returns a new targets array', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + + local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local targets = symbol_snapshot.targets_for_token(snapshot, 'foo') + table.remove(targets, 1) + + assert.equal(1, #symbol_snapshot.targets_for_token(snapshot, 'foo')) + end) + + it('keeps token variants stable, deduplicated, and whole-token first', function() + assert.same({ 'foo' }, symbol_snapshot.token_variants('foo')) + assert.same( + { 'M.actions.jump_to_file', 'actions.jump_to_file', 'jump_to_file' }, + symbol_snapshot.token_variants('M.actions.jump_to_file') + ) + assert.same( + { 'package::Type::method', 'Type::method', 'method' }, + symbol_snapshot.token_variants('package::Type::method') + ) + assert.same({ 'OpencodeApiClient:_call', '_call' }, symbol_snapshot.token_variants('OpencodeApiClient:_call')) + end) + + it('keeps token lookup exact', function() + set_file('/test/project/src/main.lua', { 'local function jump_to_file() end' }, { + { id = 1, node = fake_node('jump_to_file', 0, 15) }, + }) + + local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local exact_targets = symbol_snapshot.targets_for_token(snapshot, 'jump_to_file') + local qualified_targets = symbol_snapshot.targets_for_token(snapshot, 'M.actions.jump_to_file') + + assert.is_true(symbol_snapshot.has_token(snapshot, 'jump_to_file')) + assert.equal(1, #exact_targets) + assert.equal('jump_to_file', exact_targets[1].token) + assert.is_false(symbol_snapshot.has_token(snapshot, 'M.actions.jump_to_file')) + assert.same({}, qualified_targets) + end) +end) diff --git a/tests/unit/symbol_tokens_spec.lua b/tests/unit/symbol_tokens_spec.lua new file mode 100644 index 00000000..baf1e6f4 --- /dev/null +++ b/tests/unit/symbol_tokens_spec.lua @@ -0,0 +1,63 @@ +local assert = require('luassert') + +local symbol_tokens = require('opencode.ui.symbol_tokens') + +describe('opencode.ui.symbol_tokens', function() + it('finds plain and qualified symbol spans', function() + local cases = { + { line = 'foo', token = 'foo', start_pos = 1, end_pos = 3 }, + { line = 'foo: call', token = 'foo', start_pos = 1, end_pos = 3 }, + { line = 'foo:call', token = 'foo:call', start_pos = 1, end_pos = 8 }, + { line = 'A::b', token = 'A::b', start_pos = 1, end_pos = 4 }, + { line = 'OpencodeApiClient:_call', token = 'OpencodeApiClient:_call', start_pos = 1, end_pos = 23 }, + { line = 'M.actions.jump_to_file', token = 'M.actions.jump_to_file', start_pos = 1, end_pos = 22 }, + { line = 'foo.', token = 'foo', start_pos = 1, end_pos = 3 }, + { line = 'foo::', token = 'foo', start_pos = 1, end_pos = 3 }, + } + + for _, case in ipairs(cases) do + local start_pos, end_pos, token = symbol_tokens.find(case.line, 1) + assert.equal(case.start_pos, start_pos, case.line) + assert.equal(case.end_pos, end_pos, case.line) + assert.equal(case.token, token, case.line) + end + end) + + it('returns nil token for numeric spans', function() + local start_pos, end_pos, token = symbol_tokens.find('123', 1) + + assert.equal(1, start_pos) + assert.equal(3, end_pos) + assert.is_nil(token) + end) + + it('returns nil token for path segments', function() + local cases = { + { line = 'tests/data/symbol-reference-navigation.json', scan_from = 1, start_pos = 1, end_pos = 5 }, + { line = 'tests/data/symbol-reference-navigation.json', scan_from = 7, start_pos = 7, end_pos = 10 }, + { line = 'tests/data/symbol-reference-navigation.json', scan_from = 12, start_pos = 12, end_pos = 17 }, + { line = '.cache/', scan_from = 1, start_pos = 2, end_pos = 6 }, + { line = 'docs/plans/', scan_from = 1, start_pos = 1, end_pos = 4 }, + } + + for _, case in ipairs(cases) do + local start_pos, end_pos, token = symbol_tokens.find(case.line, case.scan_from) + assert.equal(case.start_pos, start_pos, case.line) + assert.equal(case.end_pos, end_pos, case.line) + assert.is_nil(token, case.line) + end + end) + + it('resolves tokens by zero-based cursor column', function() + assert.equal('foo', symbol_tokens.at_col('foo: call', 0)) + assert.is_nil(symbol_tokens.at_col('foo: call', 3)) + assert.equal('foo:call', symbol_tokens.at_col('foo:call', 3)) + assert.equal('OpencodeApiClient:_call', symbol_tokens.at_col('OpencodeApiClient:_call', 18)) + assert.equal('A::b', symbol_tokens.at_col('A::b', 1)) + assert.equal('A::b', symbol_tokens.at_col('A::b', 2)) + assert.equal('A::b', symbol_tokens.at_col('A::b', 3)) + assert.equal('M.actions.jump_to_file', symbol_tokens.at_col('M.actions.jump_to_file', 10)) + assert.is_nil(symbol_tokens.at_col('tests/data/symbol-reference-navigation.json', 8)) + assert.is_nil(symbol_tokens.at_col('.cache/', 2)) + end) +end) From 3be36d808bbf3a548fe95249a76e89aaa825b1af Mon Sep 17 00:00:00 2001 From: jensenojs Date: Tue, 30 Jun 2026 19:23:11 +0800 Subject: [PATCH 2/7] feat: highlight assistant file references --- lua/opencode/ui/formatter.lua | 20 + lua/opencode/ui/highlight.lua | 4 +- tests/data/api-abort.expected.json | 15 + tests/data/cursor_data.expected.json | 15 + tests/data/diagnostics.expected.json | 1088 +++++++++-------- tests/data/explore.expected.json | 45 + tests/data/markdown-codefence.expected.json | 30 + tests/data/perf.expected.json | 90 ++ .../permission-ask-new-approve.expected.json | 75 ++ tests/data/redo-all.expected.json | 223 +++- tests/data/redo-once.expected.json | 138 ++- tests/data/selection.expected.json | 15 + .../shifting-and-multiple-perms.expected.json | 111 +- tests/data/updating-text.expected.json | 45 + tests/unit/formatter_spec.lua | 6 +- 15 files changed, 1242 insertions(+), 678 deletions(-) diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index c6a93f66..c65205f6 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -690,6 +690,25 @@ local function add_symbol_reference_highlights(output, rendered, rendered_refere end end +local function add_file_reference_highlights(output, rendered, rendered_reference_ranges, first_line_idx) + local line_start = 1 + + for line_idx, line in ipairs(vim.split(rendered, '\n')) do + local line_end = line_start + #line - 1 + for _, range in ipairs(rendered_reference_ranges) do + if ranges_overlap(line_start, line_end, range[1], range[2]) then + output:add_extmark(first_line_idx + line_idx - 1, { + start_col = math.max(range[1], line_start) - line_start, + end_col = math.min(range[2], line_end) - line_start + 1, + hl_group = 'OpencodeReference', + priority = 1000, + }) + end + end + line_start = line_start + #line + 1 + end +end + ---@param output Output Output object to write to ---@param text string ---@param message_id string|nil Optional message ID for reference parsing @@ -701,6 +720,7 @@ function M._format_assistant_message(output, text, message_id) local first_line_idx = output:get_line_count() output:add_lines(vim.split(rendered, '\n')) + add_file_reference_highlights(output, rendered, rendered_reference_ranges, first_line_idx) -- Render-time symbol highlights are only visual hints. This intentionally -- rebuilds from the current conversation refs instead of storing targets on diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua index 0ca6d8d5..e72cdfb3 100644 --- a/lua/opencode/ui/highlight.lua +++ b/lua/opencode/ui/highlight.lua @@ -41,7 +41,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) - vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#1976D2', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#5F7896', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSymbolReference', { link = 'Identifier', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) @@ -90,7 +90,7 @@ function M.setup() vim.api.nvim_set_hl(0, 'OpencodeContextSwitchOn', { link = '@label', default = true }) vim.api.nvim_set_hl(0, 'OpencodePickerTime', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodeDebugText', { link = 'Comment', default = true }) - vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#7AA2F7', default = true }) + vim.api.nvim_set_hl(0, 'OpencodeReference', { fg = '#8AA6C8', default = true }) vim.api.nvim_set_hl(0, 'OpencodeSymbolReference', { link = 'Identifier', default = true }) vim.api.nvim_set_hl(0, 'OpencodeReasoningText', { link = 'Comment', default = true }) vim.api.nvim_set_hl(0, 'OpencodePermissionTitle', { fg = '#FF9E3B', default = true }) diff --git a/tests/data/api-abort.expected.json b/tests/data/api-abort.expected.json index 27fda823..151b0359 100644 --- a/tests/data/api-abort.expected.json +++ b/tests/data/api-abort.expected.json @@ -194,6 +194,21 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 9, + 10, + 86, + { + "end_col": 99, + "end_right_gravity": false, + "end_row": 10, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/data/cursor_data.expected.json b/tests/data/cursor_data.expected.json index ccc95204..cf06027c 100644 --- a/tests/data/cursor_data.expected.json +++ b/tests/data/cursor_data.expected.json @@ -314,6 +314,21 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 15, + 17, + 95, + { + "end_col": 124, + "end_right_gravity": false, + "end_row": 17, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/data/diagnostics.expected.json b/tests/data/diagnostics.expected.json index 04afd6a3..74953060 100644 --- a/tests/data/diagnostics.expected.json +++ b/tests/data/diagnostics.expected.json @@ -336,6 +336,36 @@ ], [ 12, + 12, + 63, + { + "end_col": 86, + "end_right_gravity": false, + "end_row": 12, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 13, + 15, + 20, + { + "end_col": 45, + "end_right_gravity": false, + "end_row": 15, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 14, 39, 0, { @@ -355,7 +385,7 @@ } ], [ - 13, + 15, 40, 0, { @@ -375,7 +405,7 @@ } ], [ - 14, + 16, 41, 0, { @@ -395,7 +425,7 @@ } ], [ - 15, + 17, 42, 0, { @@ -425,7 +455,7 @@ } ], [ - 16, + 18, 42, 0, { @@ -445,7 +475,7 @@ } ], [ - 17, + 19, 43, 0, { @@ -475,7 +505,7 @@ } ], [ - 18, + 20, 43, 0, { @@ -495,7 +525,7 @@ } ], [ - 19, + 21, 44, 0, { @@ -525,7 +555,7 @@ } ], [ - 20, + 22, 44, 0, { @@ -545,7 +575,7 @@ } ], [ - 21, + 23, 45, 0, { @@ -575,7 +605,7 @@ } ], [ - 22, + 24, 45, 0, { @@ -595,7 +625,7 @@ } ], [ - 23, + 25, 46, 0, { @@ -627,7 +657,7 @@ } ], [ - 24, + 26, 46, 0, { @@ -647,7 +677,7 @@ } ], [ - 25, + 27, 47, 0, { @@ -679,7 +709,7 @@ } ], [ - 26, + 28, 47, 0, { @@ -699,7 +729,7 @@ } ], [ - 27, + 29, 48, 0, { @@ -731,7 +761,7 @@ } ], [ - 28, + 30, 48, 0, { @@ -751,7 +781,7 @@ } ], [ - 29, + 31, 49, 0, { @@ -783,7 +813,7 @@ } ], [ - 30, + 32, 49, 0, { @@ -803,7 +833,7 @@ } ], [ - 31, + 33, 50, 0, { @@ -833,7 +863,7 @@ } ], [ - 32, + 34, 50, 0, { @@ -853,7 +883,7 @@ } ], [ - 33, + 35, 51, 0, { @@ -883,7 +913,7 @@ } ], [ - 34, + 36, 51, 0, { @@ -903,7 +933,7 @@ } ], [ - 35, + 37, 52, 0, { @@ -933,7 +963,7 @@ } ], [ - 36, + 38, 52, 0, { @@ -953,7 +983,7 @@ } ], [ - 37, + 39, 53, 0, { @@ -983,7 +1013,7 @@ } ], [ - 38, + 40, 53, 0, { @@ -1003,7 +1033,7 @@ } ], [ - 39, + 41, 54, 0, { @@ -1023,7 +1053,7 @@ } ], [ - 40, + 42, 55, 0, { @@ -1043,7 +1073,7 @@ } ], [ - 41, + 43, 60, 0, { @@ -1081,7 +1111,7 @@ } ], [ - 42, + 44, 60, 0, { @@ -1100,7 +1130,7 @@ } ], [ - 43, + 45, 78, 0, { @@ -1120,7 +1150,7 @@ } ], [ - 44, + 46, 79, 0, { @@ -1140,7 +1170,7 @@ } ], [ - 45, + 47, 80, 0, { @@ -1160,7 +1190,7 @@ } ], [ - 46, + 48, 81, 0, { @@ -1190,7 +1220,7 @@ } ], [ - 47, + 49, 81, 0, { @@ -1210,7 +1240,7 @@ } ], [ - 48, + 50, 82, 0, { @@ -1240,7 +1270,7 @@ } ], [ - 49, + 51, 82, 0, { @@ -1260,7 +1290,7 @@ } ], [ - 50, + 52, 83, 0, { @@ -1290,7 +1320,7 @@ } ], [ - 51, + 53, 83, 0, { @@ -1310,7 +1340,7 @@ } ], [ - 52, + 54, 84, 0, { @@ -1340,7 +1370,7 @@ } ], [ - 53, + 55, 84, 0, { @@ -1360,7 +1390,7 @@ } ], [ - 54, + 56, 85, 0, { @@ -1392,7 +1422,7 @@ } ], [ - 55, + 57, 85, 0, { @@ -1412,7 +1442,7 @@ } ], [ - 56, + 58, 86, 0, { @@ -1444,7 +1474,7 @@ } ], [ - 57, + 59, 86, 0, { @@ -1464,7 +1494,7 @@ } ], [ - 58, + 60, 87, 0, { @@ -1494,7 +1524,7 @@ } ], [ - 59, + 61, 87, 0, { @@ -1514,7 +1544,7 @@ } ], [ - 60, + 62, 88, 0, { @@ -1544,7 +1574,7 @@ } ], [ - 61, + 63, 88, 0, { @@ -1564,7 +1594,7 @@ } ], [ - 62, + 64, 89, 0, { @@ -1594,7 +1624,7 @@ } ], [ - 63, + 65, 89, 0, { @@ -1614,7 +1644,7 @@ } ], [ - 64, + 66, 90, 0, { @@ -1644,7 +1674,7 @@ } ], [ - 65, + 67, 90, 0, { @@ -1664,7 +1694,7 @@ } ], [ - 66, + 68, 91, 0, { @@ -1684,7 +1714,7 @@ } ], [ - 67, + 69, 92, 0, { @@ -1704,7 +1734,7 @@ } ], [ - 68, + 70, 97, 0, { @@ -1742,7 +1772,7 @@ } ], [ - 69, + 71, 97, 0, { @@ -1761,7 +1791,7 @@ } ], [ - 70, + 72, 105, 0, { @@ -1781,7 +1811,7 @@ } ], [ - 71, + 73, 106, 0, { @@ -1801,7 +1831,7 @@ } ], [ - 72, + 74, 107, 0, { @@ -1821,7 +1851,7 @@ } ], [ - 73, + 75, 108, 0, { @@ -1841,7 +1871,7 @@ } ], [ - 74, + 76, 109, 0, { @@ -1861,7 +1891,7 @@ } ], [ - 75, + 77, 110, 0, { @@ -1881,7 +1911,7 @@ } ], [ - 76, + 78, 111, 0, { @@ -1901,7 +1931,7 @@ } ], [ - 77, + 79, 112, 0, { @@ -1921,7 +1951,7 @@ } ], [ - 78, + 80, 113, 0, { @@ -1941,7 +1971,7 @@ } ], [ - 79, + 81, 114, 0, { @@ -1961,7 +1991,7 @@ } ], [ - 80, + 82, 115, 0, { @@ -1981,7 +2011,7 @@ } ], [ - 81, + 83, 116, 0, { @@ -2001,7 +2031,7 @@ } ], [ - 82, + 84, 117, 0, { @@ -2021,7 +2051,7 @@ } ], [ - 83, + 85, 118, 0, { @@ -2041,7 +2071,7 @@ } ], [ - 84, + 86, 119, 0, { @@ -2061,7 +2091,7 @@ } ], [ - 85, + 87, 120, 0, { @@ -2081,7 +2111,7 @@ } ], [ - 86, + 88, 121, 0, { @@ -2101,7 +2131,7 @@ } ], [ - 87, + 89, 122, 0, { @@ -2121,7 +2151,7 @@ } ], [ - 88, + 90, 123, 0, { @@ -2141,7 +2171,7 @@ } ], [ - 89, + 91, 124, 0, { @@ -2161,7 +2191,7 @@ } ], [ - 90, + 92, 125, 0, { @@ -2181,7 +2211,7 @@ } ], [ - 91, + 93, 126, 0, { @@ -2201,7 +2231,7 @@ } ], [ - 92, + 94, 127, 0, { @@ -2221,7 +2251,7 @@ } ], [ - 93, + 95, 128, 0, { @@ -2241,7 +2271,7 @@ } ], [ - 94, + 96, 129, 0, { @@ -2261,7 +2291,7 @@ } ], [ - 95, + 97, 130, 0, { @@ -2281,7 +2311,7 @@ } ], [ - 96, + 98, 131, 0, { @@ -2301,7 +2331,7 @@ } ], [ - 97, + 99, 132, 0, { @@ -2321,7 +2351,7 @@ } ], [ - 98, + 100, 133, 0, { @@ -2341,7 +2371,7 @@ } ], [ - 99, + 101, 134, 0, { @@ -2361,7 +2391,7 @@ } ], [ - 100, + 102, 135, 0, { @@ -2381,7 +2411,7 @@ } ], [ - 101, + 103, 136, 0, { @@ -2401,7 +2431,7 @@ } ], [ - 102, + 104, 137, 0, { @@ -2421,7 +2451,7 @@ } ], [ - 103, + 105, 138, 0, { @@ -2441,7 +2471,7 @@ } ], [ - 104, + 106, 139, 0, { @@ -2461,7 +2491,7 @@ } ], [ - 105, + 107, 140, 0, { @@ -2481,7 +2511,7 @@ } ], [ - 106, + 108, 141, 0, { @@ -2501,7 +2531,7 @@ } ], [ - 107, + 109, 142, 0, { @@ -2521,7 +2551,7 @@ } ], [ - 108, + 110, 143, 0, { @@ -2541,7 +2571,7 @@ } ], [ - 109, + 111, 144, 0, { @@ -2561,7 +2591,7 @@ } ], [ - 110, + 112, 145, 0, { @@ -2581,7 +2611,7 @@ } ], [ - 111, + 113, 146, 0, { @@ -2601,7 +2631,7 @@ } ], [ - 112, + 114, 147, 0, { @@ -2621,7 +2651,7 @@ } ], [ - 113, + 115, 148, 0, { @@ -2641,7 +2671,7 @@ } ], [ - 114, + 116, 149, 0, { @@ -2661,7 +2691,7 @@ } ], [ - 115, + 117, 150, 0, { @@ -2681,7 +2711,7 @@ } ], [ - 116, + 118, 151, 0, { @@ -2701,7 +2731,7 @@ } ], [ - 117, + 119, 152, 0, { @@ -2721,7 +2751,7 @@ } ], [ - 118, + 120, 153, 0, { @@ -2741,7 +2771,7 @@ } ], [ - 119, + 121, 154, 0, { @@ -2761,7 +2791,7 @@ } ], [ - 120, + 122, 155, 0, { @@ -2781,7 +2811,7 @@ } ], [ - 121, + 123, 156, 0, { @@ -2801,7 +2831,7 @@ } ], [ - 122, + 124, 157, 0, { @@ -2821,7 +2851,7 @@ } ], [ - 123, + 125, 158, 0, { @@ -2841,7 +2871,7 @@ } ], [ - 124, + 126, 159, 0, { @@ -2861,7 +2891,7 @@ } ], [ - 125, + 127, 160, 0, { @@ -2881,7 +2911,7 @@ } ], [ - 126, + 128, 161, 0, { @@ -2901,7 +2931,7 @@ } ], [ - 127, + 129, 162, 0, { @@ -2921,7 +2951,7 @@ } ], [ - 128, + 130, 163, 0, { @@ -2941,7 +2971,7 @@ } ], [ - 129, + 131, 164, 0, { @@ -2961,7 +2991,7 @@ } ], [ - 130, + 132, 165, 0, { @@ -2981,7 +3011,7 @@ } ], [ - 131, + 133, 166, 0, { @@ -3001,7 +3031,7 @@ } ], [ - 132, + 134, 167, 0, { @@ -3021,7 +3051,7 @@ } ], [ - 133, + 135, 168, 0, { @@ -3041,7 +3071,7 @@ } ], [ - 134, + 136, 169, 0, { @@ -3061,7 +3091,7 @@ } ], [ - 135, + 137, 170, 0, { @@ -3081,7 +3111,7 @@ } ], [ - 136, + 138, 171, 0, { @@ -3101,7 +3131,7 @@ } ], [ - 137, + 139, 172, 0, { @@ -3121,7 +3151,7 @@ } ], [ - 138, + 140, 173, 0, { @@ -3141,7 +3171,7 @@ } ], [ - 139, + 141, 174, 0, { @@ -3161,7 +3191,7 @@ } ], [ - 140, + 142, 175, 0, { @@ -3181,7 +3211,7 @@ } ], [ - 141, + 143, 176, 0, { @@ -3201,7 +3231,7 @@ } ], [ - 142, + 144, 177, 0, { @@ -3221,7 +3251,7 @@ } ], [ - 143, + 145, 178, 0, { @@ -3241,7 +3271,7 @@ } ], [ - 144, + 146, 179, 0, { @@ -3261,7 +3291,7 @@ } ], [ - 145, + 147, 180, 0, { @@ -3281,7 +3311,7 @@ } ], [ - 146, + 148, 181, 0, { @@ -3301,7 +3331,7 @@ } ], [ - 147, + 149, 182, 0, { @@ -3321,7 +3351,7 @@ } ], [ - 148, + 150, 183, 0, { @@ -3341,7 +3371,7 @@ } ], [ - 149, + 151, 184, 0, { @@ -3361,7 +3391,7 @@ } ], [ - 150, + 152, 185, 0, { @@ -3381,7 +3411,7 @@ } ], [ - 151, + 153, 186, 0, { @@ -3401,7 +3431,7 @@ } ], [ - 152, + 154, 187, 0, { @@ -3421,7 +3451,7 @@ } ], [ - 153, + 155, 188, 0, { @@ -3441,7 +3471,7 @@ } ], [ - 154, + 156, 189, 0, { @@ -3461,7 +3491,7 @@ } ], [ - 155, + 157, 190, 0, { @@ -3481,7 +3511,7 @@ } ], [ - 156, + 158, 191, 0, { @@ -3501,7 +3531,7 @@ } ], [ - 157, + 159, 192, 0, { @@ -3521,7 +3551,7 @@ } ], [ - 158, + 160, 193, 0, { @@ -3541,7 +3571,7 @@ } ], [ - 159, + 161, 194, 0, { @@ -3561,7 +3591,7 @@ } ], [ - 160, + 162, 195, 0, { @@ -3581,7 +3611,7 @@ } ], [ - 161, + 163, 196, 0, { @@ -3601,7 +3631,7 @@ } ], [ - 162, + 164, 197, 0, { @@ -3621,7 +3651,7 @@ } ], [ - 163, + 165, 198, 0, { @@ -3641,7 +3671,7 @@ } ], [ - 164, + 166, 199, 0, { @@ -3661,7 +3691,7 @@ } ], [ - 165, + 167, 200, 0, { @@ -3681,7 +3711,7 @@ } ], [ - 166, + 168, 201, 0, { @@ -3701,7 +3731,7 @@ } ], [ - 167, + 169, 202, 0, { @@ -3721,7 +3751,7 @@ } ], [ - 168, + 170, 203, 0, { @@ -3741,7 +3771,7 @@ } ], [ - 169, + 171, 204, 0, { @@ -3761,7 +3791,7 @@ } ], [ - 170, + 172, 205, 0, { @@ -3781,7 +3811,7 @@ } ], [ - 171, + 173, 206, 0, { @@ -3801,7 +3831,7 @@ } ], [ - 172, + 174, 207, 0, { @@ -3821,7 +3851,7 @@ } ], [ - 173, + 175, 208, 0, { @@ -3841,7 +3871,7 @@ } ], [ - 174, + 176, 209, 0, { @@ -3861,7 +3891,7 @@ } ], [ - 175, + 177, 210, 0, { @@ -3881,7 +3911,7 @@ } ], [ - 176, + 178, 211, 0, { @@ -3901,7 +3931,7 @@ } ], [ - 177, + 179, 212, 0, { @@ -3921,7 +3951,7 @@ } ], [ - 178, + 180, 213, 0, { @@ -3941,7 +3971,7 @@ } ], [ - 179, + 181, 214, 0, { @@ -3961,7 +3991,7 @@ } ], [ - 180, + 182, 215, 0, { @@ -3981,7 +4011,7 @@ } ], [ - 181, + 183, 216, 0, { @@ -4001,7 +4031,7 @@ } ], [ - 182, + 184, 217, 0, { @@ -4021,7 +4051,7 @@ } ], [ - 183, + 185, 218, 0, { @@ -4041,7 +4071,7 @@ } ], [ - 184, + 186, 219, 0, { @@ -4061,7 +4091,7 @@ } ], [ - 185, + 187, 220, 0, { @@ -4081,7 +4111,7 @@ } ], [ - 186, + 188, 221, 0, { @@ -4101,7 +4131,7 @@ } ], [ - 187, + 189, 222, 0, { @@ -4121,7 +4151,7 @@ } ], [ - 188, + 190, 223, 0, { @@ -4141,7 +4171,7 @@ } ], [ - 189, + 191, 224, 0, { @@ -4161,7 +4191,7 @@ } ], [ - 190, + 192, 225, 0, { @@ -4181,7 +4211,7 @@ } ], [ - 191, + 193, 226, 0, { @@ -4201,7 +4231,7 @@ } ], [ - 192, + 194, 227, 0, { @@ -4221,7 +4251,7 @@ } ], [ - 193, + 195, 228, 0, { @@ -4241,7 +4271,7 @@ } ], [ - 194, + 196, 229, 0, { @@ -4261,7 +4291,7 @@ } ], [ - 195, + 197, 230, 0, { @@ -4281,7 +4311,7 @@ } ], [ - 196, + 198, 231, 0, { @@ -4301,7 +4331,7 @@ } ], [ - 197, + 199, 232, 0, { @@ -4321,7 +4351,7 @@ } ], [ - 198, + 200, 233, 0, { @@ -4341,7 +4371,7 @@ } ], [ - 199, + 201, 234, 0, { @@ -4361,7 +4391,7 @@ } ], [ - 200, + 202, 235, 0, { @@ -4381,7 +4411,7 @@ } ], [ - 201, + 203, 236, 0, { @@ -4401,7 +4431,7 @@ } ], [ - 202, + 204, 237, 0, { @@ -4421,7 +4451,7 @@ } ], [ - 203, + 205, 238, 0, { @@ -4441,7 +4471,7 @@ } ], [ - 204, + 206, 239, 0, { @@ -4461,7 +4491,7 @@ } ], [ - 205, + 207, 240, 0, { @@ -4481,7 +4511,7 @@ } ], [ - 206, + 208, 241, 0, { @@ -4501,7 +4531,7 @@ } ], [ - 207, + 209, 242, 0, { @@ -4521,7 +4551,7 @@ } ], [ - 208, + 210, 243, 0, { @@ -4541,7 +4571,7 @@ } ], [ - 209, + 211, 244, 0, { @@ -4561,7 +4591,7 @@ } ], [ - 210, + 212, 245, 0, { @@ -4581,7 +4611,7 @@ } ], [ - 211, + 213, 246, 0, { @@ -4601,7 +4631,7 @@ } ], [ - 212, + 214, 247, 0, { @@ -4621,7 +4651,7 @@ } ], [ - 213, + 215, 248, 0, { @@ -4641,7 +4671,7 @@ } ], [ - 214, + 216, 249, 0, { @@ -4661,7 +4691,7 @@ } ], [ - 215, + 217, 250, 0, { @@ -4681,7 +4711,7 @@ } ], [ - 216, + 218, 251, 0, { @@ -4701,7 +4731,7 @@ } ], [ - 217, + 219, 252, 0, { @@ -4721,7 +4751,7 @@ } ], [ - 218, + 220, 253, 0, { @@ -4741,7 +4771,7 @@ } ], [ - 219, + 221, 254, 0, { @@ -4761,7 +4791,7 @@ } ], [ - 220, + 222, 255, 0, { @@ -4781,7 +4811,7 @@ } ], [ - 221, + 223, 256, 0, { @@ -4801,7 +4831,7 @@ } ], [ - 222, + 224, 257, 0, { @@ -4821,7 +4851,7 @@ } ], [ - 223, + 225, 258, 0, { @@ -4841,7 +4871,7 @@ } ], [ - 224, + 226, 259, 0, { @@ -4861,7 +4891,7 @@ } ], [ - 225, + 227, 260, 0, { @@ -4881,7 +4911,7 @@ } ], [ - 226, + 228, 261, 0, { @@ -4901,7 +4931,7 @@ } ], [ - 227, + 229, 262, 0, { @@ -4921,7 +4951,7 @@ } ], [ - 228, + 230, 263, 0, { @@ -4941,7 +4971,7 @@ } ], [ - 229, + 231, 264, 0, { @@ -4961,7 +4991,7 @@ } ], [ - 230, + 232, 265, 0, { @@ -4981,7 +5011,7 @@ } ], [ - 231, + 233, 266, 0, { @@ -5001,7 +5031,7 @@ } ], [ - 232, + 234, 267, 0, { @@ -5021,7 +5051,7 @@ } ], [ - 233, + 235, 268, 0, { @@ -5041,7 +5071,7 @@ } ], [ - 234, + 236, 269, 0, { @@ -5061,7 +5091,7 @@ } ], [ - 235, + 237, 270, 0, { @@ -5081,7 +5111,7 @@ } ], [ - 236, + 238, 271, 0, { @@ -5101,7 +5131,7 @@ } ], [ - 237, + 239, 272, 0, { @@ -5121,7 +5151,7 @@ } ], [ - 238, + 240, 273, 0, { @@ -5141,7 +5171,7 @@ } ], [ - 239, + 241, 274, 0, { @@ -5161,7 +5191,7 @@ } ], [ - 240, + 242, 275, 0, { @@ -5181,7 +5211,7 @@ } ], [ - 241, + 243, 276, 0, { @@ -5201,7 +5231,7 @@ } ], [ - 242, + 244, 277, 0, { @@ -5221,7 +5251,7 @@ } ], [ - 243, + 245, 278, 0, { @@ -5241,7 +5271,7 @@ } ], [ - 244, + 246, 279, 0, { @@ -5261,7 +5291,7 @@ } ], [ - 245, + 247, 280, 0, { @@ -5281,7 +5311,7 @@ } ], [ - 246, + 248, 281, 0, { @@ -5301,7 +5331,7 @@ } ], [ - 247, + 249, 282, 0, { @@ -5321,7 +5351,7 @@ } ], [ - 248, + 250, 283, 0, { @@ -5341,7 +5371,7 @@ } ], [ - 249, + 251, 284, 0, { @@ -5361,7 +5391,7 @@ } ], [ - 250, + 252, 285, 0, { @@ -5381,7 +5411,7 @@ } ], [ - 251, + 253, 286, 0, { @@ -5401,7 +5431,7 @@ } ], [ - 252, + 254, 287, 0, { @@ -5421,7 +5451,7 @@ } ], [ - 253, + 255, 288, 0, { @@ -5441,7 +5471,7 @@ } ], [ - 254, + 256, 289, 0, { @@ -5461,7 +5491,7 @@ } ], [ - 255, + 257, 290, 0, { @@ -5481,7 +5511,7 @@ } ], [ - 256, + 258, 291, 0, { @@ -5501,7 +5531,7 @@ } ], [ - 257, + 259, 292, 0, { @@ -5521,7 +5551,7 @@ } ], [ - 258, + 260, 293, 0, { @@ -5541,7 +5571,7 @@ } ], [ - 259, + 261, 294, 0, { @@ -5561,7 +5591,7 @@ } ], [ - 260, + 262, 295, 0, { @@ -5581,7 +5611,7 @@ } ], [ - 261, + 263, 296, 0, { @@ -5601,7 +5631,7 @@ } ], [ - 262, + 264, 297, 0, { @@ -5621,7 +5651,7 @@ } ], [ - 263, + 265, 298, 0, { @@ -5641,7 +5671,7 @@ } ], [ - 264, + 266, 299, 0, { @@ -5661,7 +5691,7 @@ } ], [ - 265, + 267, 300, 0, { @@ -5681,7 +5711,7 @@ } ], [ - 266, + 268, 301, 0, { @@ -5701,7 +5731,7 @@ } ], [ - 267, + 269, 302, 0, { @@ -5721,7 +5751,7 @@ } ], [ - 268, + 270, 303, 0, { @@ -5741,7 +5771,7 @@ } ], [ - 269, + 271, 304, 0, { @@ -5761,7 +5791,7 @@ } ], [ - 270, + 272, 305, 0, { @@ -5781,7 +5811,7 @@ } ], [ - 271, + 273, 306, 0, { @@ -5801,7 +5831,7 @@ } ], [ - 272, + 274, 307, 0, { @@ -5821,7 +5851,7 @@ } ], [ - 273, + 275, 308, 0, { @@ -5841,7 +5871,7 @@ } ], [ - 274, + 276, 309, 0, { @@ -5861,7 +5891,7 @@ } ], [ - 275, + 277, 310, 0, { @@ -5881,7 +5911,7 @@ } ], [ - 276, + 278, 311, 0, { @@ -5901,7 +5931,7 @@ } ], [ - 277, + 279, 312, 0, { @@ -5921,7 +5951,7 @@ } ], [ - 278, + 280, 313, 0, { @@ -5941,7 +5971,7 @@ } ], [ - 279, + 281, 314, 0, { @@ -5961,7 +5991,7 @@ } ], [ - 280, + 282, 315, 0, { @@ -5981,7 +6011,7 @@ } ], [ - 281, + 283, 316, 0, { @@ -6001,7 +6031,7 @@ } ], [ - 282, + 284, 317, 0, { @@ -6021,7 +6051,7 @@ } ], [ - 283, + 285, 318, 0, { @@ -6041,7 +6071,7 @@ } ], [ - 284, + 286, 319, 0, { @@ -6061,7 +6091,7 @@ } ], [ - 285, + 287, 320, 0, { @@ -6081,7 +6111,7 @@ } ], [ - 286, + 288, 321, 0, { @@ -6101,7 +6131,7 @@ } ], [ - 287, + 289, 322, 0, { @@ -6121,7 +6151,7 @@ } ], [ - 288, + 290, 323, 0, { @@ -6141,7 +6171,7 @@ } ], [ - 289, + 291, 324, 0, { @@ -6161,7 +6191,7 @@ } ], [ - 290, + 292, 325, 0, { @@ -6181,7 +6211,7 @@ } ], [ - 291, + 293, 326, 0, { @@ -6201,7 +6231,7 @@ } ], [ - 292, + 294, 327, 0, { @@ -6221,7 +6251,7 @@ } ], [ - 293, + 295, 328, 0, { @@ -6241,7 +6271,7 @@ } ], [ - 294, + 296, 329, 0, { @@ -6261,7 +6291,7 @@ } ], [ - 295, + 297, 330, 0, { @@ -6281,7 +6311,7 @@ } ], [ - 296, + 298, 331, 0, { @@ -6301,7 +6331,7 @@ } ], [ - 297, + 299, 332, 0, { @@ -6321,7 +6351,7 @@ } ], [ - 298, + 300, 333, 0, { @@ -6341,7 +6371,7 @@ } ], [ - 299, + 301, 334, 0, { @@ -6361,7 +6391,7 @@ } ], [ - 300, + 302, 335, 0, { @@ -6381,7 +6411,7 @@ } ], [ - 301, + 303, 336, 0, { @@ -6401,7 +6431,7 @@ } ], [ - 302, + 304, 337, 0, { @@ -6421,7 +6451,7 @@ } ], [ - 303, + 305, 338, 0, { @@ -6441,7 +6471,7 @@ } ], [ - 304, + 306, 339, 0, { @@ -6461,7 +6491,7 @@ } ], [ - 305, + 307, 340, 0, { @@ -6481,7 +6511,7 @@ } ], [ - 306, + 308, 341, 0, { @@ -6501,7 +6531,7 @@ } ], [ - 307, + 309, 342, 0, { @@ -6521,7 +6551,7 @@ } ], [ - 308, + 310, 343, 0, { @@ -6541,7 +6571,7 @@ } ], [ - 309, + 311, 344, 0, { @@ -6561,7 +6591,7 @@ } ], [ - 310, + 312, 345, 0, { @@ -6581,7 +6611,7 @@ } ], [ - 311, + 313, 346, 0, { @@ -6601,7 +6631,7 @@ } ], [ - 312, + 314, 347, 0, { @@ -6621,7 +6651,7 @@ } ], [ - 313, + 315, 348, 0, { @@ -6641,7 +6671,7 @@ } ], [ - 314, + 316, 349, 0, { @@ -6661,7 +6691,7 @@ } ], [ - 315, + 317, 350, 0, { @@ -6681,7 +6711,7 @@ } ], [ - 316, + 318, 351, 0, { @@ -6701,7 +6731,7 @@ } ], [ - 317, + 319, 352, 0, { @@ -6721,7 +6751,7 @@ } ], [ - 318, + 320, 353, 0, { @@ -6741,7 +6771,7 @@ } ], [ - 319, + 321, 354, 0, { @@ -6761,7 +6791,7 @@ } ], [ - 320, + 322, 355, 0, { @@ -6781,7 +6811,7 @@ } ], [ - 321, + 323, 356, 0, { @@ -6801,7 +6831,7 @@ } ], [ - 322, + 324, 357, 0, { @@ -6821,7 +6851,7 @@ } ], [ - 323, + 325, 358, 0, { @@ -6841,7 +6871,7 @@ } ], [ - 324, + 326, 359, 0, { @@ -6861,7 +6891,7 @@ } ], [ - 325, + 327, 360, 0, { @@ -6881,7 +6911,7 @@ } ], [ - 326, + 328, 361, 0, { @@ -6901,7 +6931,7 @@ } ], [ - 327, + 329, 362, 0, { @@ -6921,7 +6951,7 @@ } ], [ - 328, + 330, 363, 0, { @@ -6941,7 +6971,7 @@ } ], [ - 329, + 331, 364, 0, { @@ -6961,7 +6991,7 @@ } ], [ - 330, + 332, 365, 0, { @@ -6981,7 +7011,7 @@ } ], [ - 331, + 333, 366, 0, { @@ -7001,7 +7031,7 @@ } ], [ - 332, + 334, 367, 0, { @@ -7021,7 +7051,7 @@ } ], [ - 333, + 335, 368, 0, { @@ -7041,7 +7071,7 @@ } ], [ - 334, + 336, 369, 0, { @@ -7061,7 +7091,7 @@ } ], [ - 335, + 337, 370, 0, { @@ -7081,7 +7111,7 @@ } ], [ - 336, + 338, 371, 0, { @@ -7101,7 +7131,7 @@ } ], [ - 337, + 339, 372, 0, { @@ -7121,7 +7151,7 @@ } ], [ - 338, + 340, 373, 0, { @@ -7141,7 +7171,7 @@ } ], [ - 339, + 341, 374, 0, { @@ -7161,7 +7191,7 @@ } ], [ - 340, + 342, 375, 0, { @@ -7181,7 +7211,7 @@ } ], [ - 341, + 343, 376, 0, { @@ -7201,7 +7231,7 @@ } ], [ - 342, + 344, 377, 0, { @@ -7221,7 +7251,7 @@ } ], [ - 343, + 345, 378, 0, { @@ -7241,7 +7271,7 @@ } ], [ - 344, + 346, 379, 0, { @@ -7261,7 +7291,7 @@ } ], [ - 345, + 347, 380, 0, { @@ -7281,7 +7311,7 @@ } ], [ - 346, + 348, 381, 0, { @@ -7301,7 +7331,7 @@ } ], [ - 347, + 349, 382, 0, { @@ -7321,7 +7351,7 @@ } ], [ - 348, + 350, 383, 0, { @@ -7341,7 +7371,7 @@ } ], [ - 349, + 351, 384, 0, { @@ -7361,7 +7391,7 @@ } ], [ - 350, + 352, 385, 0, { @@ -7381,7 +7411,7 @@ } ], [ - 351, + 353, 386, 0, { @@ -7401,7 +7431,7 @@ } ], [ - 352, + 354, 387, 0, { @@ -7421,7 +7451,7 @@ } ], [ - 353, + 355, 388, 0, { @@ -7441,7 +7471,7 @@ } ], [ - 354, + 356, 389, 0, { @@ -7461,7 +7491,7 @@ } ], [ - 355, + 357, 390, 0, { @@ -7481,7 +7511,7 @@ } ], [ - 356, + 358, 391, 0, { @@ -7501,7 +7531,7 @@ } ], [ - 357, + 359, 392, 0, { @@ -7521,7 +7551,7 @@ } ], [ - 358, + 360, 393, 0, { @@ -7541,7 +7571,7 @@ } ], [ - 359, + 361, 394, 0, { @@ -7561,7 +7591,7 @@ } ], [ - 360, + 362, 395, 0, { @@ -7581,7 +7611,7 @@ } ], [ - 361, + 363, 396, 0, { @@ -7601,7 +7631,7 @@ } ], [ - 362, + 364, 397, 0, { @@ -7621,7 +7651,7 @@ } ], [ - 363, + 365, 398, 0, { @@ -7641,7 +7671,7 @@ } ], [ - 364, + 366, 399, 0, { @@ -7661,7 +7691,7 @@ } ], [ - 365, + 367, 400, 0, { @@ -7681,7 +7711,7 @@ } ], [ - 366, + 368, 401, 0, { @@ -7701,7 +7731,7 @@ } ], [ - 367, + 369, 402, 0, { @@ -7721,7 +7751,7 @@ } ], [ - 368, + 370, 403, 0, { @@ -7741,7 +7771,7 @@ } ], [ - 369, + 371, 404, 0, { @@ -7761,7 +7791,7 @@ } ], [ - 370, + 372, 405, 0, { @@ -7781,7 +7811,7 @@ } ], [ - 371, + 373, 406, 0, { @@ -7801,7 +7831,7 @@ } ], [ - 372, + 374, 407, 0, { @@ -7821,7 +7851,7 @@ } ], [ - 373, + 375, 408, 0, { @@ -7841,7 +7871,7 @@ } ], [ - 374, + 376, 409, 0, { @@ -7861,7 +7891,7 @@ } ], [ - 375, + 377, 410, 0, { @@ -7881,7 +7911,7 @@ } ], [ - 376, + 378, 411, 0, { @@ -7901,7 +7931,7 @@ } ], [ - 377, + 379, 412, 0, { @@ -7921,7 +7951,7 @@ } ], [ - 378, + 380, 413, 0, { @@ -7941,7 +7971,7 @@ } ], [ - 379, + 381, 414, 0, { @@ -7961,7 +7991,7 @@ } ], [ - 380, + 382, 415, 0, { @@ -7981,7 +8011,7 @@ } ], [ - 381, + 383, 416, 0, { @@ -8001,7 +8031,7 @@ } ], [ - 382, + 384, 417, 0, { @@ -8021,7 +8051,7 @@ } ], [ - 383, + 385, 418, 0, { @@ -8041,7 +8071,7 @@ } ], [ - 384, + 386, 419, 0, { @@ -8061,7 +8091,7 @@ } ], [ - 385, + 387, 420, 0, { @@ -8081,7 +8111,7 @@ } ], [ - 386, + 388, 421, 0, { @@ -8101,7 +8131,7 @@ } ], [ - 387, + 389, 422, 0, { @@ -8121,7 +8151,7 @@ } ], [ - 388, + 390, 423, 0, { @@ -8141,7 +8171,7 @@ } ], [ - 389, + 391, 424, 0, { @@ -8161,7 +8191,7 @@ } ], [ - 390, + 392, 425, 0, { @@ -8181,7 +8211,7 @@ } ], [ - 391, + 393, 426, 0, { @@ -8201,7 +8231,7 @@ } ], [ - 392, + 394, 427, 0, { @@ -8221,7 +8251,7 @@ } ], [ - 393, + 395, 428, 0, { @@ -8241,7 +8271,7 @@ } ], [ - 394, + 396, 429, 0, { @@ -8261,7 +8291,7 @@ } ], [ - 395, + 397, 430, 0, { @@ -8281,7 +8311,7 @@ } ], [ - 396, + 398, 431, 0, { @@ -8301,7 +8331,7 @@ } ], [ - 397, + 399, 432, 0, { @@ -8321,7 +8351,7 @@ } ], [ - 398, + 400, 433, 0, { @@ -8341,7 +8371,7 @@ } ], [ - 399, + 401, 434, 0, { @@ -8361,7 +8391,7 @@ } ], [ - 400, + 402, 435, 0, { @@ -8381,7 +8411,7 @@ } ], [ - 401, + 403, 436, 0, { @@ -8401,7 +8431,7 @@ } ], [ - 402, + 404, 437, 0, { @@ -8421,7 +8451,7 @@ } ], [ - 403, + 405, 438, 0, { @@ -8441,7 +8471,7 @@ } ], [ - 404, + 406, 439, 0, { @@ -8461,7 +8491,7 @@ } ], [ - 405, + 407, 440, 0, { @@ -8481,7 +8511,7 @@ } ], [ - 406, + 408, 441, 0, { @@ -8501,7 +8531,7 @@ } ], [ - 407, + 409, 442, 0, { @@ -8521,7 +8551,7 @@ } ], [ - 408, + 410, 443, 0, { @@ -8541,7 +8571,7 @@ } ], [ - 409, + 411, 444, 0, { @@ -8561,7 +8591,7 @@ } ], [ - 410, + 412, 445, 0, { @@ -8581,7 +8611,7 @@ } ], [ - 411, + 413, 446, 0, { @@ -8601,7 +8631,7 @@ } ], [ - 412, + 414, 447, 0, { @@ -8621,7 +8651,7 @@ } ], [ - 413, + 415, 448, 0, { @@ -8641,7 +8671,7 @@ } ], [ - 414, + 416, 449, 0, { @@ -8661,7 +8691,7 @@ } ], [ - 415, + 417, 450, 0, { @@ -8681,7 +8711,7 @@ } ], [ - 416, + 418, 451, 0, { @@ -8701,7 +8731,7 @@ } ], [ - 417, + 419, 452, 0, { @@ -8721,7 +8751,7 @@ } ], [ - 418, + 420, 453, 0, { @@ -8741,7 +8771,7 @@ } ], [ - 419, + 421, 454, 0, { @@ -8761,7 +8791,7 @@ } ], [ - 420, + 422, 455, 0, { @@ -8781,7 +8811,7 @@ } ], [ - 421, + 423, 456, 0, { @@ -8801,7 +8831,7 @@ } ], [ - 422, + 424, 457, 0, { @@ -8821,7 +8851,7 @@ } ], [ - 423, + 425, 458, 0, { @@ -8841,7 +8871,7 @@ } ], [ - 424, + 426, 459, 0, { @@ -8861,7 +8891,7 @@ } ], [ - 425, + 427, 460, 0, { @@ -8881,7 +8911,7 @@ } ], [ - 426, + 428, 461, 0, { @@ -8901,7 +8931,7 @@ } ], [ - 427, + 429, 462, 0, { @@ -8921,7 +8951,7 @@ } ], [ - 428, + 430, 463, 0, { @@ -8941,7 +8971,7 @@ } ], [ - 429, + 431, 464, 0, { @@ -8961,7 +8991,7 @@ } ], [ - 430, + 432, 465, 0, { @@ -8981,7 +9011,7 @@ } ], [ - 431, + 433, 466, 0, { @@ -9001,7 +9031,7 @@ } ], [ - 432, + 434, 467, 0, { @@ -9021,7 +9051,7 @@ } ], [ - 433, + 435, 468, 0, { @@ -9041,7 +9071,7 @@ } ], [ - 434, + 436, 469, 0, { @@ -9061,7 +9091,7 @@ } ], [ - 435, + 437, 470, 0, { @@ -9081,7 +9111,7 @@ } ], [ - 436, + 438, 471, 0, { @@ -9101,7 +9131,7 @@ } ], [ - 437, + 439, 472, 0, { @@ -9121,7 +9151,7 @@ } ], [ - 438, + 440, 473, 0, { @@ -9141,7 +9171,7 @@ } ], [ - 439, + 441, 474, 0, { @@ -9161,7 +9191,7 @@ } ], [ - 440, + 442, 475, 0, { @@ -9181,7 +9211,7 @@ } ], [ - 441, + 443, 476, 0, { @@ -9201,7 +9231,7 @@ } ], [ - 442, + 444, 477, 0, { @@ -9221,7 +9251,7 @@ } ], [ - 443, + 445, 478, 0, { @@ -9241,7 +9271,7 @@ } ], [ - 444, + 446, 479, 0, { @@ -9261,7 +9291,7 @@ } ], [ - 445, + 447, 480, 0, { @@ -9281,7 +9311,7 @@ } ], [ - 446, + 448, 481, 0, { @@ -9301,7 +9331,7 @@ } ], [ - 447, + 449, 482, 0, { @@ -9321,7 +9351,7 @@ } ], [ - 448, + 450, 483, 0, { @@ -9341,7 +9371,7 @@ } ], [ - 449, + 451, 484, 0, { @@ -9361,7 +9391,7 @@ } ], [ - 450, + 452, 485, 0, { @@ -9381,7 +9411,7 @@ } ], [ - 451, + 453, 486, 0, { @@ -9401,7 +9431,7 @@ } ], [ - 452, + 454, 487, 0, { @@ -9421,7 +9451,7 @@ } ], [ - 453, + 455, 488, 0, { @@ -9441,7 +9471,7 @@ } ], [ - 454, + 456, 489, 0, { @@ -9461,7 +9491,7 @@ } ], [ - 455, + 457, 490, 0, { @@ -9481,7 +9511,7 @@ } ], [ - 456, + 458, 491, 0, { @@ -9501,7 +9531,7 @@ } ], [ - 457, + 459, 492, 0, { @@ -9521,7 +9551,7 @@ } ], [ - 458, + 460, 493, 0, { @@ -9541,7 +9571,7 @@ } ], [ - 459, + 461, 494, 0, { @@ -9561,7 +9591,7 @@ } ], [ - 460, + 462, 495, 0, { @@ -9581,7 +9611,7 @@ } ], [ - 461, + 463, 496, 0, { @@ -9601,7 +9631,7 @@ } ], [ - 462, + 464, 497, 0, { @@ -9621,7 +9651,7 @@ } ], [ - 463, + 465, 498, 0, { @@ -9641,7 +9671,7 @@ } ], [ - 464, + 466, 499, 0, { @@ -9661,7 +9691,7 @@ } ], [ - 465, + 467, 500, 0, { @@ -9681,7 +9711,7 @@ } ], [ - 466, + 468, 501, 0, { @@ -9701,7 +9731,7 @@ } ], [ - 467, + 469, 502, 0, { @@ -9721,7 +9751,7 @@ } ], [ - 468, + 470, 503, 0, { @@ -9741,7 +9771,7 @@ } ], [ - 469, + 471, 504, 0, { @@ -9761,7 +9791,7 @@ } ], [ - 470, + 472, 505, 0, { @@ -9781,7 +9811,7 @@ } ], [ - 471, + 473, 506, 0, { @@ -9801,7 +9831,7 @@ } ], [ - 472, + 474, 507, 0, { @@ -9821,7 +9851,7 @@ } ], [ - 473, + 475, 508, 0, { @@ -9841,7 +9871,7 @@ } ], [ - 474, + 476, 509, 0, { @@ -9861,7 +9891,7 @@ } ], [ - 475, + 477, 510, 0, { @@ -9881,7 +9911,7 @@ } ], [ - 476, + 478, 511, 0, { @@ -9901,7 +9931,7 @@ } ], [ - 477, + 479, 512, 0, { @@ -9921,7 +9951,7 @@ } ], [ - 478, + 480, 513, 0, { @@ -9941,7 +9971,7 @@ } ], [ - 479, + 481, 514, 0, { @@ -9961,7 +9991,7 @@ } ], [ - 480, + 482, 515, 0, { @@ -9981,7 +10011,7 @@ } ], [ - 481, + 483, 516, 0, { @@ -10001,7 +10031,7 @@ } ], [ - 482, + 484, 517, 0, { @@ -10021,7 +10051,7 @@ } ], [ - 483, + 485, 518, 0, { @@ -10041,7 +10071,7 @@ } ], [ - 484, + 486, 519, 0, { @@ -10061,7 +10091,7 @@ } ], [ - 485, + 487, 520, 0, { @@ -10081,7 +10111,7 @@ } ], [ - 486, + 488, 521, 0, { @@ -10101,7 +10131,7 @@ } ], [ - 487, + 489, 522, 0, { @@ -10121,7 +10151,7 @@ } ], [ - 488, + 490, 523, 0, { @@ -10141,7 +10171,7 @@ } ], [ - 489, + 491, 524, 0, { @@ -10161,7 +10191,7 @@ } ], [ - 490, + 492, 525, 0, { @@ -10181,7 +10211,7 @@ } ], [ - 491, + 493, 526, 0, { @@ -10201,7 +10231,7 @@ } ], [ - 492, + 494, 527, 0, { @@ -10221,7 +10251,7 @@ } ], [ - 493, + 495, 528, 0, { @@ -10241,7 +10271,7 @@ } ], [ - 494, + 496, 529, 0, { @@ -10261,7 +10291,7 @@ } ], [ - 495, + 497, 530, 0, { @@ -10281,7 +10311,7 @@ } ], [ - 496, + 498, 531, 0, { @@ -10301,7 +10331,7 @@ } ], [ - 497, + 499, 532, 0, { @@ -10321,7 +10351,7 @@ } ], [ - 498, + 500, 533, 0, { @@ -10341,7 +10371,7 @@ } ], [ - 499, + 501, 534, 0, { @@ -10361,7 +10391,7 @@ } ], [ - 500, + 502, 535, 0, { @@ -10381,7 +10411,7 @@ } ], [ - 501, + 503, 536, 0, { @@ -10401,7 +10431,7 @@ } ], [ - 502, + 504, 537, 0, { @@ -10421,7 +10451,7 @@ } ], [ - 503, + 505, 538, 0, { @@ -10441,7 +10471,7 @@ } ], [ - 504, + 506, 539, 0, { @@ -10461,7 +10491,7 @@ } ], [ - 505, + 507, 540, 0, { @@ -10481,7 +10511,7 @@ } ], [ - 506, + 508, 541, 0, { @@ -10501,7 +10531,7 @@ } ], [ - 507, + 509, 542, 0, { @@ -10521,7 +10551,7 @@ } ], [ - 508, + 510, 543, 0, { @@ -10541,7 +10571,7 @@ } ], [ - 509, + 511, 544, 0, { @@ -10561,7 +10591,7 @@ } ], [ - 510, + 512, 545, 0, { @@ -10581,7 +10611,7 @@ } ], [ - 511, + 513, 546, 0, { @@ -10601,7 +10631,7 @@ } ], [ - 512, + 514, 547, 0, { @@ -10621,7 +10651,7 @@ } ], [ - 513, + 515, 548, 0, { @@ -10641,7 +10671,7 @@ } ], [ - 514, + 516, 549, 0, { @@ -10661,7 +10691,7 @@ } ], [ - 515, + 517, 550, 0, { @@ -10681,7 +10711,7 @@ } ], [ - 516, + 518, 551, 0, { @@ -10701,7 +10731,7 @@ } ], [ - 517, + 519, 552, 0, { @@ -10721,7 +10751,7 @@ } ], [ - 518, + 520, 553, 0, { @@ -10741,7 +10771,7 @@ } ], [ - 519, + 521, 554, 0, { @@ -10761,7 +10791,7 @@ } ], [ - 520, + 522, 555, 0, { @@ -10781,7 +10811,7 @@ } ], [ - 521, + 523, 556, 0, { @@ -10801,7 +10831,7 @@ } ], [ - 522, + 524, 557, 0, { @@ -10821,7 +10851,7 @@ } ], [ - 523, + 525, 558, 0, { @@ -10841,7 +10871,7 @@ } ], [ - 524, + 526, 559, 0, { @@ -10861,7 +10891,7 @@ } ], [ - 525, + 527, 560, 0, { @@ -10881,7 +10911,7 @@ } ], [ - 526, + 528, 561, 0, { @@ -10901,7 +10931,7 @@ } ], [ - 527, + 529, 562, 0, { @@ -10921,7 +10951,7 @@ } ], [ - 528, + 530, 563, 0, { @@ -10941,7 +10971,7 @@ } ], [ - 529, + 531, 564, 0, { @@ -10961,7 +10991,7 @@ } ], [ - 530, + 532, 565, 0, { @@ -10981,7 +11011,7 @@ } ], [ - 531, + 533, 566, 0, { @@ -11001,7 +11031,7 @@ } ], [ - 532, + 534, 567, 0, { @@ -11021,7 +11051,7 @@ } ], [ - 533, + 535, 568, 0, { @@ -11041,7 +11071,7 @@ } ], [ - 534, + 536, 569, 0, { @@ -11061,7 +11091,7 @@ } ], [ - 535, + 537, 570, 0, { @@ -11081,7 +11111,7 @@ } ], [ - 536, + 538, 571, 0, { @@ -11101,7 +11131,7 @@ } ], [ - 537, + 539, 572, 0, { @@ -11121,7 +11151,7 @@ } ], [ - 538, + 540, 573, 0, { @@ -11141,7 +11171,7 @@ } ], [ - 539, + 541, 574, 0, { @@ -11161,7 +11191,7 @@ } ], [ - 540, + 542, 577, 0, { @@ -11199,7 +11229,7 @@ } ], [ - 541, + 543, 577, 0, { diff --git a/tests/data/explore.expected.json b/tests/data/explore.expected.json index 711a0449..1d923b52 100644 --- a/tests/data/explore.expected.json +++ b/tests/data/explore.expected.json @@ -1625,6 +1625,51 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 79, + 81, + 43, + { + "end_col": 74, + "end_right_gravity": false, + "end_row": 81, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 80, + 90, + 7, + { + "end_col": 33, + "end_right_gravity": false, + "end_row": 90, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 81, + 91, + 7, + { + "end_col": 35, + "end_right_gravity": false, + "end_row": 91, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/data/markdown-codefence.expected.json b/tests/data/markdown-codefence.expected.json index 0d4fc020..98a3439f 100644 --- a/tests/data/markdown-codefence.expected.json +++ b/tests/data/markdown-codefence.expected.json @@ -1056,6 +1056,36 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 42, + 53, + 9, + { + "end_col": 31, + "end_right_gravity": false, + "end_row": 53, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 43, + 60, + 9, + { + "end_col": 34, + "end_right_gravity": false, + "end_row": 60, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/data/perf.expected.json b/tests/data/perf.expected.json index 5b062622..5bd86ccd 100644 --- a/tests/data/perf.expected.json +++ b/tests/data/perf.expected.json @@ -194,6 +194,96 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 9, + 13, + 6, + { + "end_col": 43, + "end_right_gravity": false, + "end_row": 13, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 10, + 16, + 7, + { + "end_col": 40, + "end_right_gravity": false, + "end_row": 16, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 11, + 255, + 8, + { + "end_col": 48, + "end_right_gravity": false, + "end_row": 255, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 12, + 301, + 23, + { + "end_col": 45, + "end_right_gravity": false, + "end_row": 301, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 13, + 301, + 138, + { + "end_col": 162, + "end_right_gravity": false, + "end_row": 301, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 14, + 305, + 34, + { + "end_col": 50, + "end_right_gravity": false, + "end_row": 305, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/data/permission-ask-new-approve.expected.json b/tests/data/permission-ask-new-approve.expected.json index 81bf4a2b..dcb86602 100644 --- a/tests/data/permission-ask-new-approve.expected.json +++ b/tests/data/permission-ask-new-approve.expected.json @@ -811,6 +811,81 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 37, + 44, + 8, + { + "end_col": 40, + "end_right_gravity": false, + "end_row": 44, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 38, + 45, + 8, + { + "end_col": 39, + "end_right_gravity": false, + "end_row": 45, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 39, + 46, + 8, + { + "end_col": 38, + "end_right_gravity": false, + "end_row": 46, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 40, + 49, + 8, + { + "end_col": 18, + "end_right_gravity": false, + "end_row": 49, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 41, + 50, + 8, + { + "end_col": 44, + "end_right_gravity": false, + "end_row": 50, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/data/redo-all.expected.json b/tests/data/redo-all.expected.json index b07d8795..a9fd8e25 100644 --- a/tests/data/redo-all.expected.json +++ b/tests/data/redo-all.expected.json @@ -315,6 +315,21 @@ ], [ 9, + 10, + 61, + { + "end_col": 71, + "end_right_gravity": false, + "end_row": 10, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 10, 12, 0, { @@ -334,7 +349,7 @@ } ], [ - 10, + 11, 13, 0, { @@ -354,7 +369,7 @@ } ], [ - 11, + 12, 14, 0, { @@ -374,7 +389,7 @@ } ], [ - 12, + 13, 15, 0, { @@ -406,7 +421,7 @@ } ], [ - 13, + 14, 15, 0, { @@ -426,7 +441,7 @@ } ], [ - 14, + 15, 16, 0, { @@ -458,7 +473,7 @@ } ], [ - 15, + 16, 16, 0, { @@ -478,7 +493,7 @@ } ], [ - 16, + 17, 17, 0, { @@ -508,7 +523,7 @@ } ], [ - 17, + 18, 17, 0, { @@ -528,7 +543,7 @@ } ], [ - 18, + 19, 18, 0, { @@ -558,7 +573,7 @@ } ], [ - 19, + 20, 18, 0, { @@ -578,7 +593,7 @@ } ], [ - 20, + 21, 19, 0, { @@ -598,7 +613,7 @@ } ], [ - 21, + 22, 20, 0, { @@ -618,7 +633,7 @@ } ], [ - 22, + 23, 25, 0, { @@ -656,7 +671,7 @@ } ], [ - 23, + 24, 25, 0, { @@ -675,7 +690,22 @@ } ], [ - 24, + 25, + 27, + 40, + { + "end_col": 50, + "end_right_gravity": false, + "end_row": 27, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 26, 30, 0, { @@ -713,7 +743,7 @@ } ], [ - 25, + 27, 30, 0, { @@ -732,7 +762,7 @@ } ], [ - 26, + 28, 31, 0, { @@ -752,7 +782,7 @@ } ], [ - 27, + 29, 32, 0, { @@ -772,7 +802,7 @@ } ], [ - 28, + 30, 35, 0, { @@ -810,7 +840,7 @@ } ], [ - 29, + 31, 35, 0, { @@ -829,7 +859,22 @@ } ], [ - 30, + 32, + 37, + 14, + { + "end_col": 24, + "end_right_gravity": false, + "end_row": 37, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 33, 42, 0, { @@ -867,7 +912,7 @@ } ], [ - 31, + 34, 42, 0, { @@ -886,7 +931,7 @@ } ], [ - 32, + 35, 46, 0, { @@ -906,7 +951,7 @@ } ], [ - 33, + 36, 47, 0, { @@ -926,7 +971,7 @@ } ], [ - 34, + 37, 48, 0, { @@ -946,7 +991,7 @@ } ], [ - 35, + 38, 49, 0, { @@ -978,7 +1023,7 @@ } ], [ - 36, + 39, 49, 0, { @@ -998,7 +1043,7 @@ } ], [ - 37, + 40, 50, 0, { @@ -1030,7 +1075,7 @@ } ], [ - 38, + 41, 50, 0, { @@ -1050,7 +1095,7 @@ } ], [ - 39, + 42, 51, 0, { @@ -1080,7 +1125,7 @@ } ], [ - 40, + 43, 51, 0, { @@ -1100,7 +1145,7 @@ } ], [ - 41, + 44, 52, 0, { @@ -1130,7 +1175,7 @@ } ], [ - 42, + 45, 52, 0, { @@ -1150,7 +1195,7 @@ } ], [ - 43, + 46, 53, 0, { @@ -1170,7 +1215,7 @@ } ], [ - 44, + 47, 54, 0, { @@ -1190,7 +1235,7 @@ } ], [ - 45, + 48, 59, 0, { @@ -1228,7 +1273,7 @@ } ], [ - 46, + 49, 59, 0, { @@ -1247,7 +1292,22 @@ } ], [ - 47, + 50, + 61, + 44, + { + "end_col": 54, + "end_right_gravity": false, + "end_row": 61, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 51, 64, 0, { @@ -1285,7 +1345,7 @@ } ], [ - 48, + 52, 64, 0, { @@ -1304,7 +1364,7 @@ } ], [ - 49, + 53, 65, 0, { @@ -1324,7 +1384,7 @@ } ], [ - 50, + 54, 66, 0, { @@ -1344,7 +1404,7 @@ } ], [ - 51, + 55, 69, 0, { @@ -1382,7 +1442,7 @@ } ], [ - 52, + 56, 69, 0, { @@ -1401,7 +1461,22 @@ } ], [ - 53, + 57, + 71, + 14, + { + "end_col": 24, + "end_right_gravity": false, + "end_row": 71, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 58, 76, 0, { @@ -1439,7 +1514,7 @@ } ], [ - 54, + 59, 76, 0, { @@ -1458,7 +1533,22 @@ } ], [ + 60, + 78, 55, + { + "end_col": 65, + "end_right_gravity": false, + "end_row": 78, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 61, 80, 0, { @@ -1478,7 +1568,7 @@ } ], [ - 56, + 62, 81, 0, { @@ -1498,7 +1588,7 @@ } ], [ - 57, + 63, 82, 0, { @@ -1518,7 +1608,7 @@ } ], [ - 58, + 64, 83, 0, { @@ -1550,7 +1640,7 @@ } ], [ - 59, + 65, 83, 0, { @@ -1570,7 +1660,7 @@ } ], [ - 60, + 66, 84, 0, { @@ -1602,7 +1692,7 @@ } ], [ - 61, + 67, 84, 0, { @@ -1622,7 +1712,7 @@ } ], [ - 62, + 68, 85, 0, { @@ -1652,7 +1742,7 @@ } ], [ - 63, + 69, 85, 0, { @@ -1672,7 +1762,7 @@ } ], [ - 64, + 70, 86, 0, { @@ -1702,7 +1792,7 @@ } ], [ - 65, + 71, 86, 0, { @@ -1722,7 +1812,7 @@ } ], [ - 66, + 72, 87, 0, { @@ -1742,7 +1832,7 @@ } ], [ - 67, + 73, 88, 0, { @@ -1762,7 +1852,7 @@ } ], [ - 68, + 74, 93, 0, { @@ -1800,7 +1890,7 @@ } ], [ - 69, + 75, 93, 0, { @@ -1817,6 +1907,21 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 76, + 95, + 44, + { + "end_col": 54, + "end_right_gravity": false, + "end_row": 95, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/data/redo-once.expected.json b/tests/data/redo-once.expected.json index aab1051d..d81dee60 100644 --- a/tests/data/redo-once.expected.json +++ b/tests/data/redo-once.expected.json @@ -276,6 +276,21 @@ ], [ 9, + 10, + 61, + { + "end_col": 71, + "end_right_gravity": false, + "end_row": 10, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 10, 12, 0, { @@ -295,7 +310,7 @@ } ], [ - 10, + 11, 13, 0, { @@ -315,7 +330,7 @@ } ], [ - 11, + 12, 14, 0, { @@ -335,7 +350,7 @@ } ], [ - 12, + 13, 15, 0, { @@ -367,7 +382,7 @@ } ], [ - 13, + 14, 15, 0, { @@ -387,7 +402,7 @@ } ], [ - 14, + 15, 16, 0, { @@ -419,7 +434,7 @@ } ], [ - 15, + 16, 16, 0, { @@ -439,7 +454,7 @@ } ], [ - 16, + 17, 17, 0, { @@ -469,7 +484,7 @@ } ], [ - 17, + 18, 17, 0, { @@ -489,7 +504,7 @@ } ], [ - 18, + 19, 18, 0, { @@ -519,7 +534,7 @@ } ], [ - 19, + 20, 18, 0, { @@ -539,7 +554,7 @@ } ], [ - 20, + 21, 19, 0, { @@ -559,7 +574,7 @@ } ], [ - 21, + 22, 20, 0, { @@ -579,7 +594,7 @@ } ], [ - 22, + 23, 25, 0, { @@ -617,7 +632,7 @@ } ], [ - 23, + 24, 25, 0, { @@ -636,7 +651,22 @@ } ], [ - 24, + 25, + 27, + 40, + { + "end_col": 50, + "end_right_gravity": false, + "end_row": 27, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 26, 30, 0, { @@ -674,7 +704,7 @@ } ], [ - 25, + 27, 30, 0, { @@ -693,7 +723,7 @@ } ], [ - 26, + 28, 31, 0, { @@ -713,7 +743,7 @@ } ], [ - 27, + 29, 32, 0, { @@ -733,7 +763,7 @@ } ], [ - 28, + 30, 35, 0, { @@ -771,7 +801,7 @@ } ], [ - 29, + 31, 35, 0, { @@ -790,7 +820,22 @@ } ], [ - 30, + 32, + 37, + 14, + { + "end_col": 24, + "end_right_gravity": false, + "end_row": 37, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 33, 42, 0, { @@ -828,7 +873,7 @@ } ], [ - 31, + 34, 42, 0, { @@ -847,7 +892,7 @@ } ], [ - 32, + 35, 46, 0, { @@ -867,7 +912,7 @@ } ], [ - 33, + 36, 47, 0, { @@ -887,7 +932,7 @@ } ], [ - 34, + 37, 48, 0, { @@ -907,7 +952,7 @@ } ], [ - 35, + 38, 49, 0, { @@ -939,7 +984,7 @@ } ], [ - 36, + 39, 49, 0, { @@ -959,7 +1004,7 @@ } ], [ - 37, + 40, 50, 0, { @@ -991,7 +1036,7 @@ } ], [ - 38, + 41, 50, 0, { @@ -1011,7 +1056,7 @@ } ], [ - 39, + 42, 51, 0, { @@ -1041,7 +1086,7 @@ } ], [ - 40, + 43, 51, 0, { @@ -1061,7 +1106,7 @@ } ], [ - 41, + 44, 52, 0, { @@ -1091,7 +1136,7 @@ } ], [ - 42, + 45, 52, 0, { @@ -1111,7 +1156,7 @@ } ], [ - 43, + 46, 53, 0, { @@ -1131,7 +1176,7 @@ } ], [ - 44, + 47, 54, 0, { @@ -1151,7 +1196,7 @@ } ], [ - 45, + 48, 59, 0, { @@ -1189,7 +1234,7 @@ } ], [ - 46, + 49, 59, 0, { @@ -1208,7 +1253,22 @@ } ], [ - 47, + 50, + 61, + 44, + { + "end_col": 54, + "end_right_gravity": false, + "end_row": 61, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 51, 69, 0, { @@ -1228,7 +1288,7 @@ } ], [ - 48, + 52, 69, 0, { diff --git a/tests/data/selection.expected.json b/tests/data/selection.expected.json index 9a15d207..802d5ca9 100644 --- a/tests/data/selection.expected.json +++ b/tests/data/selection.expected.json @@ -394,6 +394,21 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 19, + 18, + 23, + { + "end_col": 55, + "end_right_gravity": false, + "end_row": 18, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index 7d11115f..263b7c9f 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -197,6 +197,21 @@ ], [ 9, + 24, + 26, + { + "end_col": 40, + "end_right_gravity": false, + "end_row": 24, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 10, 83, 0, { @@ -234,7 +249,7 @@ } ], [ - 10, + 11, 83, 0, { @@ -253,7 +268,7 @@ } ], [ - 11, + 12, 84, 0, { @@ -273,7 +288,7 @@ } ], [ - 12, + 13, 85, 0, { @@ -293,7 +308,7 @@ } ], [ - 13, + 14, 88, 0, { @@ -331,7 +346,7 @@ } ], [ - 14, + 15, 88, 0, { @@ -350,7 +365,7 @@ } ], [ - 15, + 16, 111, 0, { @@ -388,7 +403,7 @@ } ], [ - 16, + 17, 111, 0, { @@ -407,7 +422,7 @@ } ], [ - 17, + 18, 112, 0, { @@ -427,7 +442,7 @@ } ], [ - 18, + 19, 113, 0, { @@ -447,7 +462,7 @@ } ], [ - 19, + 20, 116, 0, { @@ -485,7 +500,7 @@ } ], [ - 20, + 21, 116, 0, { @@ -504,7 +519,7 @@ } ], [ - 21, + 22, 125, 0, { @@ -542,7 +557,7 @@ } ], [ - 22, + 23, 127, 0, { @@ -553,7 +568,7 @@ } ], [ - 23, + 24, 127, 0, { @@ -573,7 +588,7 @@ } ], [ - 24, + 25, 128, 0, { @@ -593,7 +608,7 @@ } ], [ - 25, + 26, 129, 0, { @@ -613,7 +628,7 @@ } ], [ - 26, + 27, 130, 0, { @@ -633,7 +648,7 @@ } ], [ - 27, + 28, 131, 0, { @@ -653,7 +668,7 @@ } ], [ - 28, + 29, 132, 0, { @@ -673,7 +688,7 @@ } ], [ - 29, + 30, 133, 0, { @@ -703,7 +718,7 @@ } ], [ - 30, + 31, 133, 0, { @@ -723,7 +738,7 @@ } ], [ - 31, + 32, 134, 0, { @@ -753,7 +768,7 @@ } ], [ - 32, + 33, 134, 0, { @@ -773,7 +788,7 @@ } ], [ - 33, + 34, 135, 0, { @@ -803,7 +818,7 @@ } ], [ - 34, + 35, 135, 0, { @@ -823,7 +838,7 @@ } ], [ - 35, + 36, 136, 0, { @@ -853,7 +868,7 @@ } ], [ - 36, + 37, 136, 0, { @@ -873,7 +888,7 @@ } ], [ - 37, + 38, 137, 0, { @@ -905,7 +920,7 @@ } ], [ - 38, + 39, 137, 0, { @@ -925,7 +940,7 @@ } ], [ - 39, + 40, 138, 0, { @@ -955,7 +970,7 @@ } ], [ - 40, + 41, 138, 0, { @@ -975,7 +990,7 @@ } ], [ - 41, + 42, 139, 0, { @@ -1005,7 +1020,7 @@ } ], [ - 42, + 43, 139, 0, { @@ -1025,7 +1040,7 @@ } ], [ - 43, + 44, 140, 0, { @@ -1055,7 +1070,7 @@ } ], [ - 44, + 45, 140, 0, { @@ -1075,7 +1090,7 @@ } ], [ - 45, + 46, 141, 0, { @@ -1105,7 +1120,7 @@ } ], [ - 46, + 47, 141, 0, { @@ -1125,7 +1140,7 @@ } ], [ - 47, + 48, 142, 0, { @@ -1145,7 +1160,7 @@ } ], [ - 48, + 49, 143, 0, { @@ -1165,7 +1180,7 @@ } ], [ - 49, + 50, 144, 0, { @@ -1185,7 +1200,7 @@ } ], [ - 50, + 51, 145, 0, { @@ -1196,7 +1211,7 @@ } ], [ - 51, + 52, 145, 0, { @@ -1216,7 +1231,7 @@ } ], [ - 52, + 53, 145, 2, { @@ -1235,7 +1250,7 @@ } ], [ - 53, + 54, 146, 0, { @@ -1255,7 +1270,7 @@ } ], [ - 54, + 55, 147, 0, { @@ -1275,7 +1290,7 @@ } ], [ - 55, + 56, 148, 0, { @@ -1295,7 +1310,7 @@ } ], [ - 56, + 57, 149, 0, { @@ -1315,7 +1330,7 @@ } ], [ - 57, + 58, 150, 0, { diff --git a/tests/data/updating-text.expected.json b/tests/data/updating-text.expected.json index bab3074f..d7d951d3 100644 --- a/tests/data/updating-text.expected.json +++ b/tests/data/updating-text.expected.json @@ -194,6 +194,51 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } + ], + [ + 9, + 26, + 4, + { + "end_col": 24, + "end_right_gravity": false, + "end_row": 26, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 10, + 38, + 4, + { + "end_col": 26, + "end_right_gravity": false, + "end_row": 38, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } + ], + [ + 11, + 55, + 10, + { + "end_col": 19, + "end_right_gravity": false, + "end_row": 55, + "hl_eol": false, + "hl_group": "OpencodeReference", + "ns_id": 3, + "priority": 1000, + "right_gravity": true + } ] ], "lines": [ diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index 3756b563..45d6d557 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -384,13 +384,17 @@ describe('formatter', function() package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot local symbol_mark + local reference_mark for _, mark in ipairs(output.extmarks[0]) do if mark.hl_group == 'OpencodeSymbolReference' then symbol_mark = mark + elseif mark.hl_group == 'OpencodeReference' then + reference_mark = mark end end local trailing_foo_start = output.lines[1]:find('foo$', 1, false) - assert.are.equal(1, #output.extmarks[0]) + assert.are.equal(2, #output.extmarks[0]) + assert.is_not_nil(reference_mark) assert.is_not_nil(symbol_mark) assert.are.equal(trailing_foo_start - 1, symbol_mark.start_col) assert.are.equal(trailing_foo_start + 2, symbol_mark.end_col) From 69f51706a0691140d50147ff961148bcbffd5a70 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Thu, 2 Jul 2026 17:03:45 +0800 Subject: [PATCH 3/7] Refine reference target lifecycle --- AGENTS.md | 102 ++ lua/opencode/init.lua | 1 - lua/opencode/types.lua | 42 + lua/opencode/ui/AGENTS.md | 63 + lua/opencode/ui/autocmds.lua | 11 + lua/opencode/ui/event_scope.lua | 3 + lua/opencode/ui/formatter.lua | 291 ++++- .../ui/formatter/tools/apply_patch.lua | 2 +- lua/opencode/ui/formatter/tools/file.lua | 2 +- lua/opencode/ui/formatter/tools/task.lua | 15 +- lua/opencode/ui/formatter/utils.lua | 23 +- lua/opencode/ui/navigation.lua | 274 +---- lua/opencode/ui/output.lua | 19 +- lua/opencode/ui/reference_facts.lua | 308 +++++ lua/opencode/ui/reference_parser.lua | 193 +++ lua/opencode/ui/reference_picker.lua | 196 +-- lua/opencode/ui/render_state.lua | 98 ++ lua/opencode/ui/renderer.lua | 19 + lua/opencode/ui/renderer/buffer.lua | 93 +- lua/opencode/ui/renderer/events.lua | 87 +- lua/opencode/ui/renderer/flush.lua | 42 +- lua/opencode/ui/session_picker.lua | 5 +- lua/opencode/ui/symbol_snapshot.lua | 85 +- tests/data/api-abort.expected.json | 17 +- tests/data/cursor_data.expected.json | 17 +- tests/data/diagnostics.expected.json | 1092 ++++++++--------- tests/data/explore.expected.json | 51 +- tests/data/markdown-codefence.expected.json | 34 +- tests/data/output-target-navigation.json | 2 +- tests/data/perf.expected.json | 101 +- .../permission-ask-new-approve.expected.json | 85 +- tests/data/redo-all.expected.json | 237 +--- tests/data/redo-once.expected.json | 146 +-- tests/data/selection.expected.json | 17 +- .../shifting-and-multiple-perms.expected.json | 113 +- tests/data/updating-text.expected.json | 51 +- tests/helpers.lua | 4 +- tests/replay/renderer_spec.lua | 144 ++- tests/unit/formatter_spec.lua | 451 ++++--- tests/unit/hooks_spec.lua | 51 +- tests/unit/navigation_spec.lua | 520 ++++---- tests/unit/output_spec.lua | 44 + tests/unit/reference_facts_spec.lua | 325 +++++ tests/unit/reference_parser_spec.lua | 176 +++ tests/unit/reference_picker_spec.lua | 510 +------- tests/unit/render_state_spec.lua | 127 ++ tests/unit/renderer_buffer_spec.lua | 37 + tests/unit/renderer_targets_spec.lua | 123 ++ tests/unit/session_picker_spec.lua | 135 +- tests/unit/symbol_snapshot_spec.lua | 127 +- 50 files changed, 3970 insertions(+), 2741 deletions(-) create mode 100644 lua/opencode/ui/AGENTS.md create mode 100644 lua/opencode/ui/reference_facts.lua create mode 100644 lua/opencode/ui/reference_parser.lua create mode 100644 tests/unit/output_spec.lua create mode 100644 tests/unit/reference_facts_spec.lua create mode 100644 tests/unit/reference_parser_spec.lua create mode 100644 tests/unit/renderer_targets_spec.lua diff --git a/AGENTS.md b/AGENTS.md index 76c0eb57..4bda72e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,3 +21,105 @@ Use `scripts/dependency-topology/scan_topology.py` to inspect and track architec - Pass `--snapshot ` for historical snapshots - Pass `--json` when feeding outputs into scripts or agents - Keep architecture cleanup discussions anchored on scanner output instead of ad-hoc grep chains + +## Runtime Performance Profiling + +Use this section when there is a runtime performance problem or a credible performance report: slow render, delayed keypress, streaming lag, startup slowdown, a profiler screenshot, or a benchmark/test showing a regression. Before changing code for that problem, capture evidence and reduce it to a cost model. Pick the profiling method by what is available; do not require a specific plugin. + +### Capture options + +Use the first option that fits the machine and the symptom. + +#### Instrumentation profiler + +Use this when a profiler plugin is already available. It records function call trees with time and count. + +Example with `folke/snacks.nvim`, if it is installed: + +```vim +:lua Snacks.profiler.start() +" reproduce the slow action once +:lua Snacks.profiler.stop({ pick = true }) +``` + +Read it as a call tree. Parent time includes child time. `count` is useful for spotting repeated work. + +#### LuaJIT sampling profiler + +Use this when no profiler plugin is available. Neovim normally exposes LuaJIT's profiler as `jit.p`. + +```vim +:lua require('jit.p').start('fl', '/tmp/nvim-jit-profile.log') +" reproduce the slow action once +:lua require('jit.p').stop() +``` + +Open `/tmp/nvim-jit-profile.log`. Treat it like a sampled CPU profile: it shows where Lua spent CPU time by stack/location, but it does not give exact call counts. If the issue is repeated work, pair it with a counter or scoped timer. + +#### Scoped wall-time timer + +Use this when the question is “which lifecycle boundary blocks the user?” or when sampling does not show wall-clock delay. Add temporary instrumentation around suspected boundaries only while investigating. + +```lua +local uv = vim.uv or vim.loop +local start = uv.hrtime() +-- code under investigation +local elapsed_ms = (uv.hrtime() - start) / 1e6 +vim.notify(string.format('opencode profile: %.2fms', elapsed_ms)) +``` + +For repeated calls, accumulate count and total time: + +```lua +_G.opencode_perf = _G.opencode_perf or {} +local p = _G.opencode_perf[name] or { count = 0, total_ms = 0 } +p.count = p.count + 1 +p.total_ms = p.total_ms + elapsed_ms +_G.opencode_perf[name] = p +``` + +Remove temporary instrumentation before committing unless the user explicitly asks for a diagnostic hook. + +#### Startup profile + +Use this only for startup or plugin-load regressions: + +```bash +nvim --startuptime /tmp/nvim-startuptime.log +``` + +This is not a runtime action profiler. Do not use it to explain a slow keypress, render flush, or streaming callback. + +### Interpret the profile + +Start from the user-visible trigger, then walk down the stack. + +Record: + +```text +trigger: +blocking point: +hot stack: callee -> hotspot> +count: +cost: +repeated unit: +invariant data: +``` + +Rules for reading evidence: + +- In instrumentation traces, parent time includes child time. If parent and child times are almost equal, optimize the child or the child's call frequency. +- In sampling traces, sample share is not exact wall time and does not prove call count. Use it to find the hot stack, then verify count with instrumentation or counters. +- A 400 ms function called 10 times is a repeated-work problem. A 4 s function called once is a single expensive operation. +- Do not optimize tiny high-count helpers unless their caller stack explains the user-visible delay. + +### Fix criteria + +A valid fix must change one measured fact: + +- remove expensive work from the blocking path; +- move invariant work to the smallest valid lifecycle boundary; +- defer work to an explicit user action; +- reduce repeated calls and prove the new call count with a test. + +Do not add a cache until its invalidation boundary is named. Acceptable boundaries are concrete lifecycle points such as one render flush, one full session render, one keypress, one state change subscription, or one buffer change. Add a regression test that fails on the old call count. diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 1d873257..29eda642 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -59,7 +59,6 @@ function M.setup(opts) require('opencode.event_manager').setup() require('opencode.context').setup() require('opencode.ui.context_bar').setup() - require('opencode.ui.reference_picker').setup() end return M diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 4748d8b3..218f2513 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -545,6 +545,48 @@ ---@field display_line number Line number to display the action ---@field range? { from: number, to: number } Optional range for the action +---@class CodeReferenceTextRange +---@field start_offset integer Raw part text offset, 1-based inclusive +---@field end_offset integer Raw part text offset, 1-based inclusive + +---@class CodeReference +---@field session_id string +---@field message_id string +---@field part_id string +---@field path string +---@field line? integer +---@field col? integer +---@field source_kind 'assistant_text'|'tool_file_path' +---@field raw_range? CodeReferenceTextRange Required for assistant_text references +---@field order integer Smaller values appear earlier in the session message/part/text order. + +---@class SymbolSnapshotCycle + +---@class FormatterContext +---@field interactive boolean +---@field get_child_parts? fun(session_id: string): OpencodeMessagePart[]? +---@field current_refs? CodeReference[] +---@field current_files? string[] +---@field symbol_cycle? SymbolSnapshotCycle + +---@class OutputTargetRange +---@field line integer Output-local line, 1-based +---@field start_col integer Output-local column, 0-based inclusive +---@field end_col integer Output-local column, 0-based exclusive + +---@class OutputTarget +---@field kind 'file'|'diff'|'symbol' +---@field range OutputTargetRange +---@field path? string +---@field line? integer +---@field col? integer +---@field token? string +---@field candidate_files? string[] + +---@class RenderedTarget: OutputTarget +---@field part_id string +---@field message_id string + ---@alias OutputExtmarkType vim.api.keyset.set_extmark & {start_col:0} ---@alias OutputExtmark OutputExtmarkType|fun():OutputExtmarkType diff --git a/lua/opencode/ui/AGENTS.md b/lua/opencode/ui/AGENTS.md new file mode 100644 index 00000000..0e4b7166 --- /dev/null +++ b/lua/opencode/ui/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md (ui) + +This directory owns the rendered conversation UI and the interactive targets drawn on top of assistant text. + +## Reference target model + +The stable chain is: + +```text +assistant text + -> reference_parser: positioned mention spans + -> reference_facts: current-session refs + current executable file list + -> formatter/render: screen-coordinate file and symbol targets + -> navigation: execute the current RenderState target only +``` + +`reference_parser` only identifies text spans. It does not prove that a file exists. It must keep separate non-overlapping mentions even when they point to the same path. Path-level dedupe belongs only to picker-style file lists. + +`reference_facts` is the maintained projection from current session messages. It owns two facts: current refs from assistant text and tool file-path facts, and the current executable file list derived from those refs. A file is executable when the referenced path currently exists on disk. This file list is the authority for rendering file affordances. + +`formatter` must not parse assistant text or scan session messages. It consumes `context.current_refs` and `context.current_files`. A mention becomes an icon, highlight, and `RenderState` file target only when its path is present in `current_files`. A missing file mention stays ordinary text. + +Symbol targets are bounded by the same file list. During a render cycle, `symbol_snapshot.new_cycle()` may reuse per-file Tree-sitter work inside that cycle. Symbol truth must not become long-lived UI state. + +`navigation` consumes `RenderState` targets. It must not rediscover targets from the output buffer text. Keypress executes the target that render already produced; it is not a target lifecycle or refresh boundary. + +Assistant message updates maintain `reference_facts` incrementally. New reference mentions extend the current refs and rebuild the executable file list before the affected rendered text parts are formatted. + +`file.edited`, `file.watcher.updated`, and local buffer file lifecycle events are render invalidation boundaries. Local writes, buffer renames, buffer unloads, shell-change notifications, server file edits, and watcher add/change/unlink events can change executable files and symbol truth without changing assistant text. They refresh the reference file list and dirty currently rendered assistant text parts. The next render recreates or removes affordances through the same path: current refs, current file list, current Tree-sitter snapshot, formatter output. + +This invalidation is limited to parts already in `RenderState`. Lazy-rendered history that is not in the output buffer waits for its normal render path. In normal edits the reference file list often stays the same; only symbol truth changes, so the next render reuses the same reference files and a fresh per-render Tree-sitter cycle. + +## Expected failure diagnosis + +If a visible path does not jump, inspect in this order: + +```text +cursor position + -> renderer.get_target_at_position(line, col) + -> reference_facts.current_files() + -> formatter context for that render + -> navigation result +``` + +If `reference_facts.current_refs()` contains a mention but `renderer.get_target_at_position()` is nil, the problem is render projection or file-list membership. + +If `renderer.get_target_at_position()` returns a target but jump fails, the problem is keypress-time execution or a missing edit invalidation event. Keypress must not patch the rendered state; fix the save/edit invalidation path. + +If a nonexistent file has an icon or highlight, the bug is in render projection. Do not add cwd/root fallback code in `formatter`; fix the file list or the mention source. + +## Editing rule + +Prefer removing duplicate derivations over adding recovery paths. The UI should have one path from facts to rendered targets, and one path from rendered targets to execution. + +Do not add a second resolver layer, compatibility shim, screen-text scanner, or root fallback to hide a broken file list. + +## Regression commands + +- `./run_tests.sh -t tests/unit/reference_facts_spec.lua` +- `./run_tests.sh -t tests/unit/formatter_spec.lua` +- `./run_tests.sh -t tests/unit/navigation_spec.lua` +- `./run_tests.sh -t tests/unit/renderer_targets_spec.lua` +- `./run_tests.sh -t tests/replay/renderer_spec.lua` diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 6bceadf5..133ce972 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -39,6 +39,17 @@ function M.setup_autocmds(windows) end, }) + vim.api.nvim_create_autocmd({ 'BufWritePost', 'BufFilePost', 'BufDelete', 'BufWipeout', 'FileChangedShellPost' }, { + group = group, + pattern = '*', + callback = function(args) + if args.file == '' then + return + end + require('opencode.ui.renderer.events').invalidate_reference_targets_for_file_change() + end, + }) + vim.api.nvim_create_autocmd('WinEnter', { group = group, pattern = '*', diff --git a/lua/opencode/ui/event_scope.lua b/lua/opencode/ui/event_scope.lua index 221335ad..a93f9482 100644 --- a/lua/opencode/ui/event_scope.lua +++ b/lua/opencode/ui/event_scope.lua @@ -87,6 +87,9 @@ local policies = { ['file.edited'] = function() return true end, + ['file.watcher.updated'] = function() + return true + end, ['custom.restore_point.created'] = function() return true end, diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index c65205f6..d47d99db 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -608,32 +608,145 @@ end local function in_ranges(ranges, start_pos, end_pos) for _, range in ipairs(ranges) do - if ranges_overlap(start_pos, end_pos, range[1], range[2]) then + if ranges_overlap(start_pos, end_pos, range.start_offset, range.end_offset) then return true end end return false end -local function snapshot_has_token(symbol_snapshot, symbol_refs, token) - for _, variant in ipairs(symbol_snapshot.token_variants(token)) do - if symbol_snapshot.has_token(symbol_refs, variant) then - return true +local function available_file_set(context) + local files = {} + for _, path in ipairs((context and context.current_files) or {}) do + if type(path) == 'string' and path ~= '' then + files[path] = true end end - return false + return files +end + +local function resolve_available_path(path, available_files) + if type(path) ~= 'string' or path == '' then + return nil + end + if path:sub(1, 1) == '/' then + return available_files[path] and path or nil + end + local absolute = (vim.fn.getcwd and vim.fn.getcwd() or '') .. '/' .. path + if available_files[absolute] then + return absolute + end +end + +local function add_candidate_file(candidates, seen, available_files, path) + local absolute = resolve_available_path(path, available_files) + if absolute and not seen[absolute] then + seen[absolute] = true + candidates[#candidates + 1] = absolute + end +end + +local function output_range_for_absolute_range(rendered, first_output_line, start_offset, end_offset) + local line_start = 1 + for line_idx, line in ipairs(vim.split(rendered, '\n')) do + local line_end = line_start + #line - 1 + if ranges_overlap(line_start, line_end, start_offset, end_offset) then + return { + line = first_output_line + line_idx, + start_col = math.max(start_offset, line_start) - line_start, + end_col = math.min(end_offset, line_end) - line_start + 1, + } + end + line_start = line_start + #line + 1 + end +end + +local function current_part_index(message, part) + if not (message and message.parts and part and part.id) then + return nil + end + for index, candidate in ipairs(message.parts) do + if candidate.id == part.id then + return index + end + end +end + +local function previous_part_candidate_files(message, part, context, available_files) + local index = current_part_index(message, part) + if not index then + return {} + end + + local part_index_by_id = {} + for part_index, message_part in ipairs(message.parts or {}) do + if message_part.id then + part_index_by_id[message_part.id] = part_index + end + end + + local candidates = {} + local seen = {} + local message_id = message.info and message.info.id + for _, ref in ipairs((context and context.current_refs) or {}) do + local ref_part_index = part_index_by_id[ref.part_id] + if ref.message_id == message_id and ref_part_index and ref_part_index < index then + add_candidate_file(candidates, seen, available_files, ref.path) + end + end + + return candidates +end + +local function part_text_trim_offset(part, text) + local raw_text = part and part.text + if type(raw_text) ~= 'string' or raw_text == text then + return 0 + end + + local visible_start = raw_text:find(text, 1, true) + return visible_start and (visible_start - 1) or 0 +end + +local function current_part_text_references(part, message, text, context) + if not (part and part.id and message and message.info and message.info.id and context and context.current_refs) then + return {} + end + + local trim_offset = part_text_trim_offset(part, text) + local refs = {} + for _, ref in ipairs(context.current_refs) do + local raw_range = ref.raw_range + if + ref.source_kind == 'assistant_text' + and ref.message_id == message.info.id + and ref.part_id == part.id + and raw_range + then + local match_start = raw_range.start_offset - trim_offset + local match_end = raw_range.end_offset - trim_offset + if match_start >= 1 and match_end <= #text and match_start <= match_end then + refs[#refs + 1] = { + file_path = ref.path, + line = ref.line, + col = ref.col, + match_start = match_start, + match_end = match_end, + } + end + end + end + return refs end --- Reference icons are inserted into the rendered text, so these ranges must be --- measured after rendering. Symbol highlights use them only to stay off file --- references; jump targets are recomputed by navigation at keypress time. -local function rendered_text_with_reference_ranges(text, references) +local function rendered_text_with_reference_ranges(text, references, available_files) table.sort(references, function(a, b) return a.match_start < b.match_start end) local rendered = '' - local rendered_reference_ranges = {} + local executable_reference_ranges = {} + local rendered_mention_ranges = {} local last_pos = 1 local ref_icon = icons.get('reference') @@ -641,9 +754,21 @@ local function rendered_text_with_reference_ranges(text, references) rendered = rendered .. text:sub(last_pos, ref.match_start - 1) local ref_text = text:sub(ref.match_start, ref.match_end) - local rendered_ref_start = #rendered + #ref_icon + 1 - rendered = rendered .. ref_icon .. ref_text - table.insert(rendered_reference_ranges, { rendered_ref_start, rendered_ref_start + #ref_text - 1 }) + local absolute = resolve_available_path(ref.file_path, available_files) + local rendered_ref_start = #rendered + (absolute and #ref_icon or 0) + 1 + rendered = rendered .. (absolute and ref_icon or '') .. ref_text + local range = { + start_offset = rendered_ref_start, + end_offset = rendered_ref_start + #ref_text - 1, + path = ref.file_path, + absolute_path = absolute, + line = ref.line, + col = ref.col, + } + rendered_mention_ranges[#rendered_mention_ranges + 1] = range + if absolute then + executable_reference_ranges[#executable_reference_ranges + 1] = range + end last_pos = ref.match_end + 1 end @@ -652,11 +777,10 @@ local function rendered_text_with_reference_ranges(text, references) rendered = rendered .. text:sub(last_pos) end - return rendered, rendered_reference_ranges + return rendered, executable_reference_ranges, rendered_mention_ranges end -local function add_symbol_reference_highlights(output, rendered, rendered_reference_ranges, symbol_refs, first_line_idx) - local symbol_snapshot = require('opencode.ui.symbol_snapshot') +local function add_symbol_reference_highlights(output, rendered, rendered_mention_ranges, symbol_refs, first_line_idx) local line_start = 1 for line_idx, line in ipairs(vim.split(rendered, '\n')) do @@ -670,11 +794,7 @@ local function add_symbol_reference_highlights(output, rendered, rendered_refere local abs_start = line_start + start_pos - 1 local abs_end = line_start + end_pos - 1 - if - token - and not in_ranges(rendered_reference_ranges, abs_start, abs_end) - and snapshot_has_token(symbol_snapshot, symbol_refs, token) - then + if token and not in_ranges(rendered_mention_ranges, abs_start, abs_end) and symbol_refs[token] then output:add_extmark(first_line_idx + line_idx - 1, { start_col = start_pos - 1, end_col = end_pos, @@ -690,16 +810,97 @@ local function add_symbol_reference_highlights(output, rendered, rendered_refere end end +local function add_file_reference_targets(output, rendered, rendered_reference_ranges, first_line_idx) + for _, range in ipairs(rendered_reference_ranges) do + local output_range = output_range_for_absolute_range(rendered, first_line_idx, range.start_offset, range.end_offset) + if output_range then + output:add_target({ + kind = 'file', + path = range.absolute_path, + line = range.line, + col = range.col, + range = output_range, + }) + end + end +end + +local function add_symbol_reference_targets( + output, + rendered, + rendered_mention_ranges, + first_line_idx, + part, + message, + context +) + if not (context and context.interactive and context.symbol_cycle) then + return {} + end + + local symbol_snapshot = require('opencode.ui.symbol_snapshot') + local available_files = available_file_set(context) + local prior_part_candidates = previous_part_candidate_files(message, part, context, available_files) + local line_start = 1 + local targeted_tokens = {} + + for line_idx, line in ipairs(vim.split(rendered, '\n')) do + local scan_from = 1 + while scan_from <= #line do + local start_pos, end_pos, token = symbol_tokens.find(line, scan_from) + if not start_pos then + break + end + + local abs_start = line_start + start_pos - 1 + local abs_end = line_start + end_pos - 1 + + if token and not in_ranges(rendered_mention_ranges, abs_start, abs_end) then + local candidates = {} + local seen = {} + for _, range in ipairs(rendered_mention_ranges) do + if range.end_offset < abs_start then + add_candidate_file(candidates, seen, available_files, range.path) + end + end + if #candidates == 0 then + candidates = prior_part_candidates + end + + if #candidates > 0 and #symbol_snapshot.targets_for_token(context.symbol_cycle, token, candidates) > 0 then + output:add_target({ + kind = 'symbol', + token = token, + candidate_files = vim.deepcopy(candidates), + range = { + line = first_line_idx + line_idx, + start_col = start_pos - 1, + end_col = end_pos, + }, + }) + targeted_tokens[token] = true + end + end + + scan_from = end_pos + 1 + end + + line_start = line_start + #line + 1 + end + + return targeted_tokens +end + local function add_file_reference_highlights(output, rendered, rendered_reference_ranges, first_line_idx) local line_start = 1 for line_idx, line in ipairs(vim.split(rendered, '\n')) do local line_end = line_start + #line - 1 for _, range in ipairs(rendered_reference_ranges) do - if ranges_overlap(line_start, line_end, range[1], range[2]) then + if ranges_overlap(line_start, line_end, range.start_offset, range.end_offset) then output:add_extmark(first_line_idx + line_idx - 1, { - start_col = math.max(range[1], line_start) - line_start, - end_col = math.min(range[2], line_end) - line_start + 1, + start_col = math.max(range.start_offset, line_start) - line_start, + end_col = math.min(range.end_offset, line_end) - line_start + 1, hl_group = 'OpencodeReference', priority = 1000, }) @@ -711,29 +912,29 @@ end ---@param output Output Output object to write to ---@param text string ----@param message_id string|nil Optional message ID for reference parsing -function M._format_assistant_message(output, text, message_id) - local reference_picker = require('opencode.ui.reference_picker') - local symbol_snapshot = require('opencode.ui.symbol_snapshot') - local references = reference_picker.parse_references(text, message_id or text) - local rendered, rendered_reference_ranges = rendered_text_with_reference_ranges(text, references) +---@param part? OpencodeMessagePart +---@param message? OpencodeMessage +---@param context? FormatterContext +function M._format_assistant_message(output, text, part, message, context) + local references = current_part_text_references(part, message, text, context) + local rendered, rendered_reference_ranges, rendered_mention_ranges = + rendered_text_with_reference_ranges(text, references, available_file_set(context)) local first_line_idx = output:get_line_count() output:add_lines(vim.split(rendered, '\n')) + if context and context.interactive then + add_file_reference_targets(output, rendered, rendered_reference_ranges, first_line_idx) + end add_file_reference_highlights(output, rendered, rendered_reference_ranges, first_line_idx) - - -- Render-time symbol highlights are only visual hints. This intentionally - -- rebuilds from the current conversation refs instead of storing targets on - -- extmarks; navigation recomputes the snapshot before jumping. - local refs = reference_picker.collect_refs() - local symbol_refs = symbol_snapshot.collect(refs) - add_symbol_reference_highlights(output, rendered, rendered_reference_ranges, symbol_refs, first_line_idx) + local targeted_tokens = + add_symbol_reference_targets(output, rendered, rendered_mention_ranges, first_line_idx, part, message, context) + add_symbol_reference_highlights(output, rendered, rendered_mention_ranges, targeted_tokens, first_line_idx) end ---@param output Output Output object to write to ---@param part OpencodeMessagePart ----@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]? -function M.format_tool(output, part, get_child_parts) +---@param context FormatterContext +function M.format_tool(output, part, context) local tool = part.tool if not tool or not part.state then return @@ -743,7 +944,7 @@ function M.format_tool(output, part, get_child_parts) local formatter = tool_formatters[tool] or (tool:match('_') and tool_formatters.mcp) or tool_formatters.tool local fold_count = #output.fold_ranges - formatter.format(output, part, get_child_parts) + formatter.format(output, part, context) if not format_utils.should_fold_tool(tool) then for idx = #output.fold_ranges, fold_count + 1, -1 do @@ -792,9 +993,9 @@ end ---@param part OpencodeMessagePart The part to format ---@param message? OpencodeMessage Optional message object to extract role and mentions from ---@param is_last_part? boolean Whether this is the last part in the message, used to show an error if there is one ----@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]? +---@param context FormatterContext ---@return Output -function M.format_part(part, message, is_last_part, get_child_parts) +function M.format_part(part, message, is_last_part, context) local output = Output.new() if not message or not message.info or not message.info.role then @@ -844,13 +1045,13 @@ function M.format_part(part, message, is_last_part, get_child_parts) end elseif role == 'assistant' then if part.type == 'text' and part.text then - M._format_assistant_message(output, vim.trim(part.text), part.id or part.messageID) + M._format_assistant_message(output, vim.trim(part.text), part, message, context) content_added = true elseif part.type == 'reasoning' then M._format_reasoning(output, part) content_added = true elseif part.type == 'tool' then - M.format_tool(output, part, get_child_parts) + M.format_tool(output, part, context) content_added = true elseif part.type == 'patch' and part.hash then M._format_patch(output, part) diff --git a/lua/opencode/ui/formatter/tools/apply_patch.lua b/lua/opencode/ui/formatter/tools/apply_patch.lua index 6e202bee..8c2158fd 100644 --- a/lua/opencode/ui/formatter/tools/apply_patch.lua +++ b/lua/opencode/ui/formatter/tools/apply_patch.lua @@ -41,7 +41,7 @@ function M.format(output, part) if (config.ui.output.tools.show_output or config.ui.output.tools.use_folds) and patch then local start_line = output:get_line_count() + 1 local file_type = file and util.get_markdown_filetype(file.filePath) or '' - formatter_utils.format_diff(output, patch, file_type) + formatter_utils.format_diff(output, patch, file_type, file.filePath) output:add_fold_with_threshold(start_line, config.ui.output.tools.show_output, config.ui.output.tools.use_folds) end end diff --git a/lua/opencode/ui/formatter/tools/file.lua b/lua/opencode/ui/formatter/tools/file.lua index a4401cb0..b1be12d8 100644 --- a/lua/opencode/ui/formatter/tools/file.lua +++ b/lua/opencode/ui/formatter/tools/file.lua @@ -71,7 +71,7 @@ function M.format(output, part) end if tool_type == 'edit' and metadata.diff then - utils.format_diff(output, metadata.diff, file_type) + utils.format_diff(output, metadata.diff, file_type, input.filePath) elseif tool_type == 'write' and input.content then utils.format_code(output, vim.split(input.content, '\n'), file_type) end diff --git a/lua/opencode/ui/formatter/tools/task.lua b/lua/opencode/ui/formatter/tools/task.lua index 8bb8c8d0..b639b2ac 100644 --- a/lua/opencode/ui/formatter/tools/task.lua +++ b/lua/opencode/ui/formatter/tools/task.lua @@ -23,8 +23,8 @@ end ---@param output Output ---@param part OpencodeMessagePart ----@param get_child_parts? fun(session_id: string): OpencodeMessagePart[]? -function M.format(output, part, get_child_parts) +---@param context? FormatterContext +function M.format(output, part, context) if part.tool ~= 'task' then return end @@ -49,7 +49,10 @@ function M.format(output, part, get_child_parts) local output_start_line = output:get_line_count() + 1 if config.ui.output.tools.show_output or config.ui.output.tools.use_folds then local child_session_id = metadata.sessionId - local child_parts = child_session_id and get_child_parts and get_child_parts(child_session_id) + local child_parts = child_session_id + and context + and context.get_child_parts + and context.get_child_parts(child_session_id) if child_parts and #child_parts > 0 then output:add_empty_line() @@ -73,7 +76,11 @@ function M.format(output, part, get_child_parts) end end - output:add_fold_with_threshold(output_start_line, config.ui.output.tools.show_output, config.ui.output.tools.use_folds) + output:add_fold_with_threshold( + output_start_line, + config.ui.output.tools.show_output, + config.ui.output.tools.use_folds + ) end local end_line = output:get_line_count() diff --git a/lua/opencode/ui/formatter/utils.lua b/lua/opencode/ui/formatter/utils.lua index 35cdf509..7c858132 100644 --- a/lua/opencode/ui/formatter/utils.lua +++ b/lua/opencode/ui/formatter/utils.lua @@ -119,7 +119,7 @@ local function build_diff_gutter(line_numbers, width) return string.format('%' .. width .. 's', line_number and tostring(line_number) or '') end -local function add_diff_line(output, line, line_numbers, width) +local function add_diff_line(output, line, line_numbers, width, source_path) local first_char = line:sub(1, 1) local line_hl = first_char == '+' and 'OpencodeDiffAdd' or first_char == '-' and 'OpencodeDiffDelete' or nil local gutter_hl = first_char == '+' and 'OpencodeDiffAddGutter' @@ -129,9 +129,23 @@ local function add_diff_line(output, line, line_numbers, width) local gutter = build_diff_gutter(line_numbers, width) local gutter_width = #gutter + 2 - output:add_line(string.rep(' ', gutter_width) .. line:sub(2)) + local rendered_line = string.rep(' ', gutter_width) .. line:sub(2) + output:add_line(rendered_line) local line_idx = output:get_line_count() + if source_path and line_numbers.new then + output:add_target({ + kind = 'diff', + path = source_path, + line = line_numbers.new, + range = { + line = line_idx, + start_col = 0, + end_col = #rendered_line, + }, + }) + end + local extmark = { end_col = 0, end_row = line_idx, @@ -159,7 +173,8 @@ end ---@param output Output ---@param code string ---@param file_type string -function M.format_diff(output, code, file_type) +---@param source_path? string +function M.format_diff(output, code, file_type, source_path) output:add_empty_line() --- NOTE: use longer code fence because code could contain ``` @@ -172,7 +187,7 @@ function M.format_diff(output, code, file_type) for idx, line in ipairs(lines) do local source_idx = first_visible_line + idx - 1 if numbered_lines[source_idx] then - add_diff_line(output, line, numbered_lines[source_idx], line_number_width) + add_diff_line(output, line, numbered_lines[source_idx], line_number_width, source_path) else output:add_line(line) end diff --git a/lua/opencode/ui/navigation.lua b/lua/opencode/ui/navigation.lua index b2ab1ff3..3e0feac3 100644 --- a/lua/opencode/ui/navigation.lua +++ b/lua/opencode/ui/navigation.lua @@ -3,8 +3,6 @@ local M = {} local state = require('opencode.state') local config = require('opencode.config') local renderer = require('opencode.ui.renderer') -local output_window = require('opencode.ui.output_window') -local symbol_tokens = require('opencode.ui.symbol_tokens') function M.goto_message_by_id(message_id) require('opencode.ui.ui').focus_output() @@ -119,198 +117,32 @@ local function resolve_path(raw) if vim.uv.fs_stat(absolute) then return absolute end - local found = vim.fn.findfile(raw, '.;') - if found ~= '' then - return found - end -end - -local function parse_path_location(raw) - if raw:match('^%a[%w+.-]*://') and not raw:match('^file://') then - return nil - end - - local path = raw:gsub('^file://', '') - local line, col - local p, l, c = path:match('^(.-):(%d+):(%d+)$') - if p then - path, line, col = p, tonumber(l), tonumber(c) - else - p, l = path:match('^(.-):(%d+)$') - if p then - path, line = p, tonumber(l) - end - end - - if path == '' then - return nil - end - - return { path = path, line = line, col = col } -end - -local function contains_col(start_pos, end_pos, col) - return col >= start_pos - 1 and col <= end_pos - 1 -end - -local function add_file_candidate(candidates, line, pattern, path_capture_index) - path_capture_index = path_capture_index or 2 - local captures = { line:match(pattern) } - while #captures > 0 do - local start_pos = captures[1] - local end_pos = captures[#captures] - 1 - local raw = captures[path_capture_index] - table.insert(candidates, { - start_pos = start_pos, - end_pos = end_pos, - target = parse_path_location(raw), - }) - local next_start = end_pos + 2 - captures = { line:match(pattern, next_start) } - end -end - -local function file_target_at_col(line, col) - local candidates = {} - - add_file_candidate(candidates, line, '()%[%`([^`]+)%`%]%([^%)]+%)()') - add_file_candidate(candidates, line, '()%`([^`\n]+%.%w+:?%d*:?%d*)%`()') - add_file_candidate(candidates, line, '()file://([%S]+%.%w+:?%d*:?%d*)()') - add_file_candidate(candidates, line, '()%*%*.-%*%*%s+%`([^`]+)%`()') - add_file_candidate(candidates, line, '()([%w_./%-]+/[%w_./%-]*%.%w+:?%d*:?%d*)()') - add_file_candidate(candidates, line, '()([%w_%-]+%.%w+:?%d*:?%d*)()') - - for _, candidate in ipairs(candidates) do - if - candidate.target - and contains_col(candidate.start_pos, candidate.end_pos, col) - and resolve_path(candidate.target.path) - then - return candidate.target - end - end -end - -local function first_file_target(line) - local max_col = math.max(#line - 1, 0) - for col = 0, max_col do - local target = file_target_at_col(line, col) - if target then - return target - end - end -end - -local function symbol_token_at_col(line, col) - return symbol_tokens.at_col(line, col) -end - -local function cursor_symbol_token() - local windows = state.windows or {} - local win = windows.output_win - local buf = windows.output_buf - - if not win or not buf or not vim.api.nvim_win_is_valid(win) then - return nil - end - - local cursor = vim.api.nvim_win_get_cursor(win) - local line = vim.api.nvim_buf_get_lines(buf, cursor[1] - 1, cursor[1], false)[1] - if not line then - return nil - end - - return symbol_token_at_col(line, cursor[2]) -end - -local function diff_line_number(buf, line_num) - local ns = output_window.namespace - local extmarks = vim.api.nvim_buf_get_extmarks(buf, ns, { line_num - 1, 0 }, { line_num - 1, -1 }, { details = true }) - for _, extmark in ipairs(extmarks) do - local details = extmark[4] - local virt_text = details and details.virt_text - if virt_text then - local gutter = virt_text[1] and virt_text[1][1] - local sign = virt_text[2] and virt_text[2][1] - if sign == '-' then - return nil - end - if sign == '+' or sign == ' ' then - return tonumber(vim.trim(gutter or '')) - end - end - end -end - ----Resolve file and line number at cursor position in the output buffer. ----@return { path: string, line: number?, col: number? }? -function M.resolve_file_at_cursor() - local windows = state.windows or {} - local win = windows.output_win - local buf = windows.output_buf - - if not win or not buf or not vim.api.nvim_win_is_valid(win) then - return nil - end - - local cursor = vim.api.nvim_win_get_cursor(win) - local line_num = cursor[1] - local col = cursor[2] - local line = vim.api.nvim_buf_get_lines(buf, line_num - 1, line_num, false)[1] - - if not line then - return nil - end - - local target = file_target_at_col(line, col) - if target then - return target - end - - local file_path = nil - for i = line_num, 1, -1 do - local l = vim.api.nvim_buf_get_lines(buf, i - 1, i, false)[1] - if l then - local found = first_file_target(l) - if found then - file_path = found.path - break - end - end - end - - if not file_path then - return nil - end - - local ln = diff_line_number(buf, line_num) - if not ln then - return nil - end - - return { path = file_path, line = ln } end ---Open a file in the current window without triggering BufRead/BufNew autocmds. ---Falls back to :edit if the file isn't loaded in any buffer yet. ---@param path string +---@return boolean local function open_silent(path) local escaped = vim.fn.fnameescape(path) if not pcall(function() vim.cmd('buffer ' .. escaped) end) then - pcall(function() + return pcall(function() vim.cmd('edit ' .. escaped) end) end + return true end local function open_at(win, path, line, col) if not win or not vim.api.nvim_win_is_valid(win) then - return + return false end vim.api.nvim_set_current_win(win) - open_silent(path) + if not open_silent(path) then + return false + end if line then local buf = vim.api.nvim_win_get_buf(win) local line_count = vim.api.nvim_buf_line_count(buf) @@ -324,6 +156,7 @@ local function open_at(win, path, line, col) pcall(vim.api.nvim_win_set_cursor, win, { target_line, target_col }) vim.cmd('normal! zz') end + return true end local function best_target_win() @@ -340,46 +173,29 @@ end ---@param path string ---@param line? number ---@param col? number +---@return boolean function M.navigate_to_location(path, line, col) local resolved_path = resolve_path(path) if not resolved_path then - return + return false end local target_win = best_target_win() local windows = state.windows if config.ui.position == 'current' and windows and target_win == windows.output_win then require('opencode.ui.ui').hide_visible_windows(windows) end - open_at(target_win, resolved_path, line, col) + return open_at(target_win, resolved_path, line, col) end function M.resolve_target_at_cursor() - return M.resolve_file_at_cursor() -end - -local function target_key(target) - return table.concat({ target.path or '', target.line or 0, target.col or 0 }, ':') -end - -local function symbol_targets_for_token(token) - local reference_picker = require('opencode.ui.reference_picker') - local symbol_snapshot = require('opencode.ui.symbol_snapshot') - local refs = reference_picker.collect_refs() - local snapshot = symbol_snapshot.collect(refs) - local targets = {} - local seen = {} - - for _, variant in ipairs(symbol_snapshot.token_variants(token)) do - for _, target in ipairs(symbol_snapshot.targets_for_token(snapshot, variant)) do - local key = target_key(target) - if not seen[key] then - seen[key] = true - table.insert(targets, target) - end - end + local windows = state.windows or {} + local win = windows.output_win + if not win or not vim.api.nvim_win_is_valid(win) then + return nil end - return targets + local cursor = vim.api.nvim_win_get_cursor(win) + return renderer.get_target_at_position(cursor[1], cursor[2]) end local function format_symbol_target(target, width) @@ -399,7 +215,7 @@ local function format_symbol_target(target, width) ) end -local function pick_symbol_target(token, targets) +local function pick_symbol_target(targets) return require('opencode.ui.base_picker').pick({ items = targets, format_fn = format_symbol_target, @@ -416,43 +232,65 @@ local function pick_symbol_target(token, targets) }) end -local function jump_to_symbol_at_cursor() - local token = cursor_symbol_token() - if not token then +local function target_at_cursor(filter) + local windows = state.windows or {} + local win = windows.output_win + if not win or not vim.api.nvim_win_is_valid(win) then + return nil + end + + local cursor = vim.api.nvim_win_get_cursor(win) + return renderer.get_target_at_position(cursor[1], cursor[2], filter) +end + +local function jump_to_symbol_target(target) + if not target.token then return end - local targets = symbol_targets_for_token(token) + local symbol_snapshot = require('opencode.ui.symbol_snapshot') + local targets = + symbol_snapshot.targets_for_token(symbol_snapshot.new_cycle(), target.token, target.candidate_files or {}) if #targets == 0 then - vim.notify('No symbol target found: ' .. token, vim.log.levels.INFO) + vim.notify('No symbol target found: ' .. target.token, vim.log.levels.INFO) return end if #targets == 1 then - local target = targets[1] - M.navigate_to_location(target.path, target.line, target.col) + local resolved = targets[1] + M.navigate_to_location(resolved.path, resolved.line, resolved.col) return end - pick_symbol_target(token, targets) + pick_symbol_target(targets) end -function M.jump_to_target_at_cursor() - local resolved = M.resolve_target_at_cursor() - if resolved then - M.navigate_to_location(resolved.path, resolved.line, resolved.col) +local function jump_to_rendered_target(target) + if target.kind == 'file' or target.kind == 'diff' then + M.navigate_to_location(target.path, target.line, target.col) return end - jump_to_symbol_at_cursor() + if target.kind == 'symbol' then + jump_to_symbol_target(target) + end +end + +function M.jump_to_target_at_cursor() + local target = target_at_cursor() + if target then + jump_to_rendered_target(target) + end end function M.jump_to_file_at_cursor() - local resolved = M.resolve_file_at_cursor() - if not resolved then + local target = target_at_cursor(function(candidate) + return candidate.kind == 'file' or candidate.kind == 'diff' + end) + if not target then return end - M.navigate_to_location(resolved.path, resolved.line, resolved.col) + jump_to_rendered_target(target) end return M diff --git a/lua/opencode/ui/output.lua b/lua/opencode/ui/output.lua index dbfc6c3b..a1c31048 100644 --- a/lua/opencode/ui/output.lua +++ b/lua/opencode/ui/output.lua @@ -7,6 +7,7 @@ Output.__index = Output ---@field lines string[] ---@field extmarks table ---@field actions OutputAction[] +---@field targets OutputTarget[] ---@field add_line fun(self: Output, line: string, fit?: boolean): number ---@field get_line fun(self: Output, idx: number): string? ---@field merge_line fun(self: Output, idx: number, text: string) @@ -20,12 +21,15 @@ Output.__index = Output ---@field add_actions fun(self: Output, actions: OutputAction[]) ---@field add_action fun(self: Output, action: OutputAction) ---@field get_actions_for_line fun(self: Output, line: number): OutputAction[]? +---@field add_target fun(self: Output, target: OutputTarget) +---@field add_targets fun(self: Output, targets: OutputTarget[]) ---@return self Output function Output.new() local self = setmetatable({}, Output) self.lines = {} self.extmarks = {} self.actions = {} + self.targets = {} self.fold_ranges = {} return self end @@ -79,11 +83,12 @@ function Output:add_empty_line() return nil end ----Clear all lines, extmarks, and actions +---Clear all lines, extmarks, actions, and targets function Output:clear() self.lines = {} self.extmarks = {} self.actions = {} + self.targets = {} end ---Add a fold range @@ -162,6 +167,18 @@ function Output:add_action(action) table.insert(self.actions, action) end +---@param target OutputTarget +function Output:add_target(target) + table.insert(self.targets, target) +end + +---@param targets OutputTarget[] +function Output:add_targets(targets) + for _, target in ipairs(targets) do + self:add_target(target) + end +end + ---Get actions for a line matching a range ---@param line number The line index to check ---@return OutputAction[]|nil diff --git a/lua/opencode/ui/reference_facts.lua b/lua/opencode/ui/reference_facts.lua new file mode 100644 index 00000000..2a395bd2 --- /dev/null +++ b/lua/opencode/ui/reference_facts.lua @@ -0,0 +1,308 @@ +local M = {} + +local reference_parser = require('opencode.ui.reference_parser') + +local current_session_id = nil +local messages_by_id = {} +local next_message_order = 1 +local current_files = {} + +local function relative_path(path) + if path:sub(1, 1) ~= '/' then + return path + end + return vim.fn.fnamemodify(path, ':~:.') +end + +local function absolute_path(path) + if path:sub(1, 1) == '/' then + return path + end + return vim.fn.getcwd() .. '/' .. path +end + +local function file_is_available(path) + local absolute = absolute_path(path) + if vim.fn.filereadable(absolute) == 1 then + return true, absolute + end + return false, absolute +end + +local function is_current_session_assistant_message(session_id, message) + return current_session_id == session_id + and message + and message.info + and message.info.sessionID == session_id + and message.info.role == 'assistant' + and not (message.info.id and message.info.id:match('^__opencode_')) +end + +local function collect_part_refs(session_id, message, part, message_order, part_order) + if not is_current_session_assistant_message(session_id, message) or not part or part.synthetic or not part.id then + return {} + end + + local refs = {} + local message_id = message.info.id + + if part.type == 'text' and part.text then + for ref_order, parsed in ipairs(reference_parser.parse_references(part.text, part.id)) do + table.insert(refs, { + session_id = session_id, + message_id = message_id, + part_id = part.id, + path = parsed.file_path, + line = parsed.line, + col = parsed.col, + source_kind = 'assistant_text', + raw_range = { + start_offset = parsed.match_start, + end_offset = parsed.match_end, + }, + order = message_order * 1000000 + part_order * 1000 + ref_order, + }) + end + elseif part.type == 'tool' then + local file_path = vim.tbl_get(part, 'state', 'input', 'filePath') + if file_path and file_path ~= '' then + table.insert(refs, { + session_id = session_id, + message_id = message_id, + part_id = part.id, + path = relative_path(file_path), + source_kind = 'tool_file_path', + order = message_order * 1000000 + part_order * 1000 + 1, + }) + end + end + + return refs +end + +local function refs_equal(a, b) + if #(a or {}) ~= #(b or {}) then + return false + end + for i = 1, #a do + local left = a[i] + local right = b[i] + if + left.path ~= right.path + or left.line ~= right.line + or left.col ~= right.col + or left.source_kind ~= right.source_kind + then + return false + end + end + return true +end + +local function all_refs() + local entries = {} + for _, entry in pairs(messages_by_id) do + entries[#entries + 1] = entry + end + table.sort(entries, function(a, b) + return a.order < b.order + end) + + local refs = {} + for _, entry in ipairs(entries) do + local parts = {} + for _, part_entry in pairs(entry.parts) do + parts[#parts + 1] = part_entry + end + table.sort(parts, function(a, b) + return a.order < b.order + end) + + for _, part_entry in ipairs(parts) do + for _, ref in ipairs(part_entry.refs) do + refs[#refs + 1] = ref + end + end + end + + return refs +end + +local function rebuild_current_files() + current_files = {} + local seen = {} + for _, ref in ipairs(all_refs()) do + local available, absolute = file_is_available(ref.path) + if available and not seen[absolute] then + seen[absolute] = true + current_files[#current_files + 1] = absolute + end + end +end + +local function ensure_message_entry(message) + local message_id = message and message.info and message.info.id + if not message_id then + return nil + end + + local entry = messages_by_id[message_id] + if not entry then + entry = { + message = message, + order = next_message_order, + parts = {}, + } + next_message_order = next_message_order + 1 + messages_by_id[message_id] = entry + end + entry.message = message + return entry +end + +local function replace_part_entry(session_id, message, part) + local message_id = message and message.info and message.info.id + local part_id = part and part.id + if not message_id or not part_id then + return false + end + + if not is_current_session_assistant_message(session_id, message) then + local entry = messages_by_id[message_id] + if entry and entry.parts[part_id] then + entry.parts[part_id] = nil + return true + end + return false + end + + local entry = ensure_message_entry(message) + local part_order = 1 + for index, candidate in ipairs(message.parts or {}) do + if candidate.id == part_id then + part_order = index + break + end + end + + local old_refs = entry.parts[part_id] and entry.parts[part_id].refs or {} + local refs = collect_part_refs(session_id, message, part, entry.order, part_order) + if #refs > 0 then + entry.parts[part_id] = { order = part_order, refs = refs } + else + entry.parts[part_id] = nil + end + return not refs_equal(old_refs, refs) +end + +function M.clear() + current_session_id = nil + messages_by_id = {} + next_message_order = 1 + current_files = {} + reference_parser.clear_all() +end + +---@param session_id string +---@param messages OpencodeMessage[] +function M.rebuild(session_id, messages) + current_session_id = session_id + messages_by_id = {} + next_message_order = 1 + reference_parser.clear_all() + + for message_order, message in ipairs(messages or {}) do + if is_current_session_assistant_message(session_id, message) then + local entry = { + message = message, + order = message_order, + parts = {}, + } + messages_by_id[message.info.id] = entry + next_message_order = math.max(next_message_order, message_order + 1) + + for part_order, part in ipairs(message.parts or {}) do + if part.id then + local refs = collect_part_refs(session_id, message, part, message_order, part_order) + if #refs > 0 then + entry.parts[part.id] = { order = part_order, refs = refs } + end + end + end + end + end + + rebuild_current_files() +end + +---@param session_id string +---@param message OpencodeMessage +---@param part OpencodeMessagePart +---@return boolean refs_changed +function M.replace_part(session_id, message, part) + if not current_session_id then + current_session_id = session_id + end + local changed = replace_part_entry(session_id, message, part) + if changed then + rebuild_current_files() + end + return changed +end + +---@param message_id string +---@param part_id string +---@return boolean refs_changed +function M.remove_part(message_id, part_id) + reference_parser.clear(part_id) + local entry = messages_by_id[message_id] + local had_refs = entry and entry.parts[part_id] and #(entry.parts[part_id].refs or {}) > 0 + if entry then + entry.parts[part_id] = nil + end + if had_refs then + rebuild_current_files() + end + return had_refs == true +end + +---@param message_id string +---@return boolean refs_changed +function M.remove_message(message_id) + local entry = messages_by_id[message_id] + local had_refs = false + if entry then + for part_id, part_entry in pairs(entry.parts) do + reference_parser.clear(part_id) + if #(part_entry.refs or {}) > 0 then + had_refs = true + end + end + end + messages_by_id[message_id] = nil + if had_refs then + rebuild_current_files() + end + return had_refs +end + +---@return CodeReference[] +function M.current_refs() + local refs = {} + for _, ref in ipairs(all_refs()) do + refs[#refs + 1] = vim.deepcopy(ref) + end + + return refs +end + +---@return string[] +function M.current_files() + return vim.deepcopy(current_files) +end + +function M.refresh_current_files() + rebuild_current_files() +end + +return M diff --git a/lua/opencode/ui/reference_parser.lua b/lua/opencode/ui/reference_parser.lua new file mode 100644 index 00000000..7ba59010 --- /dev/null +++ b/lua/opencode/ui/reference_parser.lua @@ -0,0 +1,193 @@ +---@class ParsedCodeReference +---@field file_path string +---@field line number|nil +---@field col number|nil +---@field match_start number +---@field match_end number + +local M = {} + +local PATTERNS = { + { pat = '`([^`\n]+%.(%w+)):?(%d*):?(%d*)`' }, + { pat = 'file://([%S]+%.(%w+)):?(%d*):?(%d*)' }, + { pat = '([%w_./%-]+/[%w_./%-]*%.(%w+)):?(%d*):?(%d*)' }, + { pat = '([%w_%-]+%.(%w+)):?(%d*):?(%d*)' }, +} + +local OVERLAP = 128 +local cache = {} + +local function is_valid_ext(ext) + return #ext >= 1 and #ext <= 5 and ext:match('^%a+$') ~= nil +end + +local function is_url_path(path, chunk, ms) + local context = chunk:sub(math.max(1, ms - 64), ms - 1) + return context:match('https?://[%S]*$') + or context:match('www%.[%S]*$') + or path:match('^//') + or path:match('^www%.') + or path:match('^[%w%-]+%.[%w%-]+/') +end + +local function current_line_start_before(text, offset) + return text:sub(1, offset - 1):match('.*\n()') or 1 +end + +local function unclosed_inline_backtick_before(text, offset) + local line_start = current_line_start_before(text, offset) + local line_prefix = text:sub(line_start, offset - 1):gsub('```', ' ') + local _, backticks_before_offset = line_prefix:gsub('`', '') + if backticks_before_offset % 2 == 0 then + return nil + end + local relative_offset = line_prefix:match('^.*()`') + return relative_offset and (line_start + relative_offset - 1) or nil +end + +local function is_inside_unclosed_inline_backticks(text, offset) + if not unclosed_inline_backtick_before(text, offset) then + return false + end + local line_end = text:find('\n', offset, true) or (#text + 1) + local closing_backtick = text:find('`', offset, true) + return not (closing_backtick and closing_backtick < line_end) +end + +local function fenced_code_ranges(text) + local ranges = {} + local fence_start = nil + local line_start = 1 + + while line_start <= #text do + local newline = text:find('\n', line_start, true) + local line_end = newline and (newline - 1) or #text + local line = text:sub(line_start, line_end) + + if line:match('^%s*```') then + if fence_start then + ranges[#ranges + 1] = { fence_start, newline or line_end } + fence_start = nil + else + fence_start = line_start + end + end + + if not newline then + break + end + line_start = newline + 1 + end + + if fence_start then + ranges[#ranges + 1] = { fence_start, #text } + end + + return ranges +end + +local function inside_range(ranges, offset) + for _, range in ipairs(ranges) do + if offset >= range[1] and offset <= range[2] then + return true + end + end + return false +end + +local function overlaps(ranges, abs_ms, abs_me) + for _, r in ipairs(ranges) do + if abs_ms <= r[2] and abs_me >= r[1] then + return true + end + end + return false +end + +local function make_ref(path, line_str, col_str, abs_start, abs_end) + return { + file_path = path, + line = line_str ~= '' and tonumber(line_str) or nil, + col = col_str ~= '' and tonumber(col_str) or nil, + match_start = abs_start, + match_end = abs_end, + } +end + +local function parse_references_into(text, c, scan_from) + local chunk = text:sub(scan_from) + local abs_offset = scan_from - 1 + local fenced_ranges = fenced_code_ranges(text) + + for pattern_index, entry in ipairs(PATTERNS) do + local pos = 1 + while pos <= #chunk do + local ms, me, path, ext, l, col = chunk:find(entry.pat, pos) + if not ms then + break + end + + if is_valid_ext(ext) then + local abs_ms = ms + abs_offset + local abs_me = me + abs_offset + if + not inside_range(fenced_ranges, abs_ms) + and not is_url_path(path, chunk, ms) + and (pattern_index == 1 or not is_inside_unclosed_inline_backticks(text, abs_ms)) + and not overlaps(c.ranges, abs_ms, abs_me) + then + table.insert(c.ranges, { abs_ms, abs_me }) + table.insert(c.refs, make_ref(path, l or '', col or '', abs_ms, abs_me)) + end + end + + pos = me + 1 + end + end +end + +local function append_scan_from(text, parsed_upto) + local scan_from = math.max(1, parsed_upto - OVERLAP + 1) + return unclosed_inline_backtick_before(text, scan_from) or scan_from +end + +---@param text string +---@param key string +---@return ParsedCodeReference[] +function M.parse_references(text, key) + local c = cache[key] + if c and text == c.text then + return c.refs + end + + local scan_from = 1 + if c and vim.startswith(text, c.text) then + scan_from = append_scan_from(text, c.parsed_upto) + else + c = { + text = '', + parsed_upto = 0, + refs = {}, + ranges = {}, + } + cache[key] = c + end + + local len = #text + parse_references_into(text, c, scan_from) + + c.text = text + c.parsed_upto = len + return c.refs +end + +---@param key string +function M.clear(key) + cache[key] = nil +end + +function M.clear_all() + cache = {} +end + +return M diff --git a/lua/opencode/ui/reference_picker.lua b/lua/opencode/ui/reference_picker.lua index 167e0485..aa1bc803 100644 --- a/lua/opencode/ui/reference_picker.lua +++ b/lua/opencode/ui/reference_picker.lua @@ -1,28 +1,9 @@ -local state = require('opencode.state') local config = require('opencode.config') local base_picker = require('opencode.ui.base_picker') local icons = require('opencode.ui.icons') ----@class CodeReference ----@field file_path string ----@field line number|nil ----@field col number|nil ----@field match_start number ----@field match_end number - local M = {} -local PATTERNS = { - { pat = '`([^`\n]+%.(%w+)):?(%d*):?(%d*)`', check_exists = false }, - { pat = 'file://([%S]+%.(%w+)):?(%d*):?(%d*)', check_exists = false }, - { pat = '([%w_./%-]+/[%w_./%-]*%.(%w+)):?(%d*):?(%d*)', check_exists = false }, - { pat = '([%w_%-]+%.(%w+)):?(%d*):?(%d*)', check_exists = true }, -} - -local OVERLAP = 128 -local cache = {} -local exists_cache = {} - local function make_absolute_path(path) if not vim.startswith(path, '/') then return vim.fn.getcwd() .. '/' .. path @@ -30,133 +11,9 @@ local function make_absolute_path(path) return path end -local function file_exists(path) - local abs = make_absolute_path(path) - if exists_cache[abs] == nil then - exists_cache[abs] = vim.fn.filereadable(abs) == 1 - end - return exists_cache[abs] -end - -local function is_valid_ext(ext) - return #ext >= 1 and #ext <= 5 and ext:match('^%a+$') ~= nil -end - -local function is_url_path(path, chunk, ms) - local context = chunk:sub(math.max(1, ms - 64), ms - 1) - return context:match('https?://[%S]*$') - or context:match('www%.[%S]*$') - or path:match('^//') - or path:match('^www%.') - or path:match('^[%w%-]+%.[%w%-]+/') -end - -local function overlaps(ranges, abs_ms, abs_me) - for _, r in ipairs(ranges) do - if abs_ms <= r[2] and abs_me >= r[1] then - return true - end - end - return false -end - -local function make_ref(path, line_str, col_str, abs_start, abs_end) - return { - file_path = path, - line = line_str ~= '' and tonumber(line_str) or nil, - col = col_str ~= '' and tonumber(col_str) or nil, - match_start = abs_start, - match_end = abs_end, - } -end - -local function picker_ref_key(path, line) - return make_absolute_path(path) .. ':' .. (line or 0) -end - -local function parse_references_into(text, c, scan_from) - local chunk = text:sub(scan_from) - local abs_offset = scan_from - 1 - - for _, entry in ipairs(PATTERNS) do - local pos = 1 - while pos <= #chunk do - local ms, me, path, ext, l, col = chunk:find(entry.pat, pos) - if not ms then - break - end - - if is_valid_ext(ext) then - local abs_ms = ms + abs_offset - local abs_me = me + abs_offset - local path_key = path .. ':' .. (l or '') .. ':' .. (col or '') - - if - not is_url_path(path, chunk, ms) - and not c.seen_paths[path_key] - and not overlaps(c.ranges, abs_ms, abs_me) - and (not entry.check_exists or file_exists(path)) - then - c.seen_paths[path_key] = true - table.insert(c.ranges, { abs_ms, abs_me }) - table.insert(c.refs, make_ref(path, l or '', col or '', abs_ms, abs_me)) - end - end - - pos = me + 1 - end - end -end - -local function parse_references_uncached(text) - local c = { - refs = {}, - ranges = {}, - seen_paths = {}, - } - parse_references_into(text, c, 1) - return c.refs -end - ----@param text string ----@param message_id string ----@return CodeReference[] -function M.parse_references(text, message_id) - local c = cache[message_id] - if not c then - c = { - parsed_upto = 0, - refs = {}, - ranges = {}, - seen_paths = {}, - } - cache[message_id] = c - end - - local len = #text - if len <= c.parsed_upto then - return c.refs - end - - local scan_from = math.max(1, c.parsed_upto - OVERLAP + 1) - parse_references_into(text, c, scan_from) - - c.parsed_upto = len - return c.refs -end - -function M.clear(message_id) - cache[message_id] = nil -end - -function M.clear_all() - cache = {} - exists_cache = {} -end - local function format_reference_item(ref, width) local icon = icons.get('file') - local location = ref.file_path + local location = ref.path if ref.line then location = location .. ':' .. ref.line if ref.col then @@ -166,48 +23,21 @@ local function format_reference_item(ref, width) return base_picker.create_time_picker_item(icon .. ' ' .. location, nil, nil, width) end -function M.collect_refs() - if not state.messages then - return {} - end - +local function display_refs(refs) + local items = {} local seen = {} - local refs = {} - - local function add_ref(ref) - local key = picker_ref_key(ref.file_path, ref.line) + for _, ref in ipairs(refs or {}) do + local key = make_absolute_path(ref.path) .. ':' .. (ref.line or 0) if not seen[key] then seen[key] = true - table.insert(refs, ref) - end - end - - for i = #state.messages, 1, -1 do - local msg = state.messages[i] - if msg.info and msg.info.role == 'assistant' then - if msg.parts then - for _, part in ipairs(msg.parts) do - if part.type == 'text' and part.text then - for _, ref in ipairs(parse_references_uncached(part.text)) do - add_ref(ref) - end - elseif part.type == 'tool' then - local file_path = vim.tbl_get(part, 'state', 'input', 'filePath') - if file_path and vim.fn.filereadable(file_path) == 1 then - local rel = vim.fn.fnamemodify(file_path, ':~:.') - add_ref(make_ref(rel, '', '', 0, 0)) - end - end - end - end + items[#items + 1] = ref end end - - return refs + return items end function M.pick() - local refs = M.collect_refs() + local refs = display_refs(require('opencode.ui.reference_facts').current_refs()) if #refs == 0 then vim.notify('No code references found in the conversation', vim.log.levels.INFO) return @@ -230,7 +60,7 @@ function M.pick() end function M.navigate_to(ref) - local file_path = make_absolute_path(ref.file_path) + local file_path = make_absolute_path(ref.path) if vim.fn.filereadable(file_path) ~= 1 then vim.notify('File not found: ' .. file_path, vim.log.levels.WARN) return @@ -246,12 +76,4 @@ function M.navigate_to(ref) end end ----Setup reference picker event subscriptions ----Should be called once during plugin initialization -function M.setup() - state.store.subscribe('messages', function() - M.clear_all() - end) -end - return M diff --git a/lua/opencode/ui/render_state.lua b/lua/opencode/ui/render_state.lua index b98814bd..cacb8478 100644 --- a/lua/opencode/ui/render_state.lua +++ b/lua/opencode/ui/render_state.lua @@ -9,6 +9,7 @@ ---@field line_start integer? Line where part starts ---@field line_end integer? Line where part ends ---@field actions table[] Actions associated with this part +---@field targets RenderedTarget[] Targets associated with this part ---@field has_extmarks boolean? Whether the part currently has extmarks applied ---@class RenderState @@ -366,6 +367,86 @@ function RenderState:get_actions_at_line(line) return actions end +---@param target RenderedTarget +---@param line integer 1-indexed +---@param col integer 0-indexed +---@return boolean +local function target_contains_position(target, line, col) + local range = target.range + return range.line == line and col >= range.start_col and col < range.end_col +end + +---@param target RenderedTarget +---@return integer +local function target_priority(target) + if target.kind == 'file' or target.kind == 'diff' then + return 1 + end + if target.kind == 'symbol' then + return 2 + end + return 3 +end + +---@param target RenderedTarget +---@return integer +local function target_width(target) + return target.range.end_col - target.range.start_col +end + +---@param part_id string +---@param targets OutputTarget[] +---@param offset? integer Line offset to apply to target line numbers +function RenderState:add_targets(part_id, targets, offset) + local part_data = self._parts[part_id] + if not part_data then + return + end + offset = offset or 0 + + for _, target in ipairs(targets) do + local rendered_target = vim.deepcopy(target) + rendered_target.range.line = rendered_target.range.line + offset + rendered_target.part_id = part_id + rendered_target.message_id = part_data.message_id + part_data.targets[#part_data.targets + 1] = rendered_target + end +end + +---@param part_id string +function RenderState:clear_targets(part_id) + local part_data = self._parts[part_id] + if part_data then + part_data.targets = {} + end +end + +---@param line integer 1-indexed +---@param col integer 0-indexed +---@param filter? fun(target: RenderedTarget): boolean +---@return RenderedTarget? +function RenderState:get_target_at_position(line, col, filter) + local best = nil + local best_priority = nil + local best_width = nil + + for _, part_data in pairs(self._parts) do + for _, target in ipairs(part_data.targets or {}) do + if target_contains_position(target, line, col) and (not filter or filter(target)) then + local priority = target_priority(target) + local width = target_width(target) + if not best or priority < best_priority or (priority == best_priority and width < best_width) then + best = target + best_priority = priority + best_width = width + end + end + end + end + + return best and vim.deepcopy(best) or nil +end + ---@param part_id string ---@param actions table[] ---@param offset? integer Line offset to apply to action line numbers @@ -410,6 +491,14 @@ function RenderState:get_all_actions() return all_actions end +---@param targets RenderedTarget[] +---@param delta integer +local function shift_targets(targets, delta) + for _, target in ipairs(targets or {}) do + target.range.line = target.range.line + delta + end +end + ---@param message OpencodeMessage ---@param line_start integer? ---@param line_end integer? @@ -462,6 +551,7 @@ function RenderState:set_part(part, line_start, line_end) line_start = line_start, line_end = line_end, actions = {}, + targets = {}, has_extmarks = false, } else @@ -469,6 +559,9 @@ function RenderState:set_part(part, line_start, line_end) if message_id then existing.message_id = message_id end + if line_start and existing.line_start and existing.line_start ~= line_start then + shift_targets(existing.targets, line_start - existing.line_start) + end if line_start then existing.line_start = line_start end @@ -506,12 +599,16 @@ function RenderState:update_part_lines(part_id, new_line_start, new_line_end) end local old_line_end = part_data.line_end + local old_line_start = part_data.line_start local old_line_count = old_line_end - part_data.line_start + 1 local new_line_count = new_line_end - new_line_start + 1 local delta = new_line_count - old_line_count part_data.line_start = new_line_start part_data.line_end = new_line_end + if old_line_start ~= new_line_start then + shift_targets(part_data.targets, new_line_start - old_line_start) + end self._ranges_valid = false if self._max_line_end_valid then @@ -669,6 +766,7 @@ function RenderState:shift_all(from_line, delta) for _, action in ipairs(part_data.actions) do shift_action(action, delta) end + shift_targets(part_data.targets, delta) end end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index 986f66e1..852b239c 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -2,6 +2,7 @@ local state = require('opencode.state') local config = require('opencode.config') local output_window = require('opencode.ui.output_window') local permission_window = require('opencode.ui.permission_window') +local reference_facts = require('opencode.ui.reference_facts') local Promise = require('opencode.promise') local ctx = require('opencode.ui.renderer.ctx') local events = require('opencode.ui.renderer.events') @@ -276,6 +277,7 @@ function M.event_subscriptions() { 'question.replied', events.on_question_replied }, { 'question.rejected', events.on_question_replied }, { 'file.edited', events.on_file_edited }, + { 'file.watcher.updated', events.on_file_watcher_updated }, { 'custom.restore_point.created', events.on_restore_points }, { 'custom.emit_events.finished', M.on_emit_events_finished }, } @@ -284,6 +286,7 @@ end ---Reset all renderer state and clear the output buffer function M.reset() ctx:reset() + reference_facts.clear() output_window.clear() permission_window.clear_all() state.renderer.reset() @@ -350,6 +353,8 @@ function M._render_full_session_data(session_data, opts) return end + reference_facts.rebuild(state.active_session.id, state.messages) + local visible_messages, hidden_count = get_visible_session_messages(state.messages) local revert_index = get_revert_index(state.messages) @@ -588,6 +593,20 @@ function M.get_actions_for_line(line) return ctx.render_state:get_actions_at_line(line) end +---@param line integer 1-indexed +---@param col integer 0-indexed +---@param filter? fun(target: RenderedTarget): boolean +---@return RenderedTarget|nil +function M.get_target_at_position(line, col, filter) + return ctx.render_state:get_target_at_position(line, col, filter) +end + +---@param part_id string +---@param message_id string +function M.mark_part_dirty(part_id, message_id) + flush.mark_part_dirty(part_id, message_id) +end + ---Return the rendered message record for a given message ID ---@param message_id string ---@return RenderedMessage|nil diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index e6cd3566..3ff9fa04 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -264,7 +264,8 @@ end ---@param new_line_end integer ---@param skip_clear? boolean local function apply_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end, skip_clear) - local clear_start, clear_end = extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) + local clear_start, clear_end = + extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) if not skip_clear then output_window.clear_extmarks(clear_start, clear_end) end @@ -282,7 +283,8 @@ end ---@param old_line_end integer ---@param new_line_end integer local function apply_appended_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) - local clear_start, clear_end = extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) + local clear_start, clear_end = + extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) clear_start = math.max(clear_start, old_line_end + 1) if clear_start >= clear_end then return @@ -430,7 +432,7 @@ end ---@param part_id string ---@param formatted_data Output ---@param line_start integer -local function apply_part_actions(part_id, formatted_data, line_start) +local function apply_part_render_data(part_id, formatted_data, line_start) if has_actions(formatted_data.actions) then ctx.render_state:clear_actions(part_id) ctx.render_state:add_actions(part_id, vim.deepcopy(formatted_data.actions), line_start) @@ -438,15 +440,9 @@ local function apply_part_actions(part_id, formatted_data, line_start) ctx.render_state:clear_actions(part_id) end - local part_data = ctx.render_state:get_part(part_id) - if part_data then - part_data.has_extmarks = has_extmarks(formatted_data.extmarks) - end -end + ctx.render_state:clear_targets(part_id) + ctx.render_state:add_targets(part_id, vim.deepcopy(formatted_data.targets or {}), line_start) ----@param part_id string ----@param formatted_data Output -local function set_part_extmark_state(part_id, formatted_data) local part_data = ctx.render_state:get_part(part_id) if part_data then part_data.has_extmarks = has_extmarks(formatted_data.extmarks) @@ -497,7 +493,7 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) if ctx.bulk_mode then local line_start = #ctx.bulk_buffer_lines local line_end = line_start + #formatted_data.lines - 1 - + for _, line in ipairs(formatted_data.lines) do ctx.bulk_buffer_lines[#ctx.bulk_buffer_lines + 1] = line end @@ -507,15 +503,15 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) if formatted_data.fold_ranges then accumulate_bulk_folds(formatted_data.fold_ranges, line_start) end - + local message_data = ctx.render_state:get_message(message_id) if message_data then ctx.render_state:set_message(message_data.message, line_start, line_end) end - + return true end - + local cached = ctx.render_state:get_message(message_id) if cached and cached.line_start and cached.line_end then local old_line_end = cached.line_end @@ -529,15 +525,15 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) old_line_end, cached.line_start + #formatted_data.lines - 1 ) - + output_window.clear_extmarks(clear_start, clear_end) output_window.set_lines(lines_to_write, write_start, cached.line_end + 1) highlight_written_lines(write_start, lines_to_write) - + local new_line_end = cached.line_start + #formatted_data.lines - 1 apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, true) ctx.render_state:set_message(cached.message, cached.line_start, new_line_end) - + local delta = new_line_end - old_line_end if delta ~= 0 then ctx.render_state:shift_all(old_line_end + 1, delta) @@ -545,7 +541,7 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) end return true end - + local insert_at = get_message_insert_line(message_id) local message_data = ctx.render_state:get_message(message_id) if message_data and message_data.message then @@ -553,17 +549,16 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) if has_extmarks(formatted_data.extmarks) then output_window.set_extmarks(formatted_data.extmarks, insert_at) end - + ctx.render_state:shift_all(insert_at, #formatted_data.lines) output_window.shift_folds(insert_at, #formatted_data.lines) ctx.render_state:set_message(message_data.message, range.line_start, range.line_end) return true end - + return false end - ---@param part_id string ---@param message_id string ---@param formatted_data Output @@ -573,7 +568,7 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt if ctx.bulk_mode then local line_start = #ctx.bulk_buffer_lines local line_end = line_start + #formatted_data.lines - 1 - + for _, line in ipairs(formatted_data.lines) do ctx.bulk_buffer_lines[#ctx.bulk_buffer_lines + 1] = line end @@ -583,16 +578,16 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt if formatted_data.fold_ranges then accumulate_bulk_folds(formatted_data.fold_ranges, line_start) end - + local part_data = ctx.render_state:get_part(part_id) if part_data then ctx.render_state:set_part(part_data.part, line_start, line_end) - apply_part_actions(part_id, formatted_data, line_start) + apply_part_render_data(part_id, formatted_data, line_start) end - + return true end - + local cached = ctx.render_state:get_part(part_id) if cached and cached.line_start and cached.line_end then local old_line_end = cached.line_end @@ -606,19 +601,18 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt old_line_end, cached.line_start + #formatted_data.lines - 1 ) - + output_window.clear_extmarks(clear_start, clear_end) output_window.set_lines(lines_to_write, write_start, cached.line_end + 1) highlight_written_lines(write_start, lines_to_write) - + local new_line_end = cached.line_start + #formatted_data.lines - 1 - apply_part_actions(part_id, formatted_data, cached.line_start) - + apply_part_render_data(part_id, formatted_data, cached.line_start) + if new_line_end ~= cached.line_end then ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) end apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, true) - set_part_extmark_state(part_id, formatted_data) if formatted_data.fold_ranges and #formatted_data.fold_ranges > 0 then M.update_part_folds(part_id) @@ -627,7 +621,7 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt return true end -local insert_at = get_part_insertion_line(part_id, message_id) + local insert_at = get_part_insertion_line(part_id, message_id) if not insert_at then return false end @@ -638,11 +632,10 @@ local insert_at = get_part_insertion_line(part_id, message_id) ctx.render_state:shift_all(insert_at, #formatted_data.lines) output_window.shift_folds(insert_at, #formatted_data.lines) ctx.render_state:set_part(part_data.part, range.line_start, range.line_end) - apply_part_actions(part_id, formatted_data, range.line_start) + apply_part_render_data(part_id, formatted_data, range.line_start) if has_extmarks(formatted_data.extmarks) then output_window.set_extmarks(formatted_data.extmarks, range.line_start) end - set_part_extmark_state(part_id, formatted_data) if formatted_data.fold_ranges and #formatted_data.fold_ranges > 0 then M.set_all_folds() @@ -679,10 +672,16 @@ function M.set_all_folds() end local function folds_equal(a, b) - if not a or not b then return false end - if #a ~= #b then return false end + if not a or not b then + return false + end + if #a ~= #b then + return false + end for i = 1, #a do - if a[i].from ~= b[i].from or a[i].to ~= b[i].to then return false end + if a[i].from ~= b[i].from or a[i].to ~= b[i].to then + return false + end end return true end @@ -720,12 +719,13 @@ function M.update_part_folds(part_id) table.insert(new_global, f) end end - table.sort(new_global, function(a, b) return a.from < b.from end) + table.sort(new_global, function(a, b) + return a.from < b.from + end) ctx.global_folds = new_global output_window.set_folds(new_global) end - ---@param part_id string ---@param extra_lines string[] ---@param extra_extmarks table|nil @@ -747,9 +747,8 @@ function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_format local formatted_data = ctx.formatted_parts[part_id] if formatted_data then - apply_part_actions(part_id, formatted_data, cached.line_start) + apply_part_render_data(part_id, formatted_data, cached.line_start) apply_appended_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end) - set_part_extmark_state(part_id, formatted_data) if formatted_data.fold_ranges then M.update_part_folds(part_id) end @@ -768,13 +767,13 @@ function M.remove_part_now(part_id) ctx.render_state:remove_part(part_id) return end - + local cached = ctx.render_state:get_part(part_id) if not cached or not cached.line_start or not cached.line_end then ctx.render_state:remove_part(part_id) return end - + output_window.clear_extmarks(cached.line_start - 1, cached.line_end + 1) output_window.set_lines({}, cached.line_start, cached.line_end + 1) local delta = -(cached.line_end - cached.line_start + 1) @@ -782,7 +781,6 @@ function M.remove_part_now(part_id) ctx.render_state:remove_part(part_id) end - ---@param message_id string function M.remove_message_now(message_id) if ctx.bulk_mode then @@ -791,13 +789,13 @@ function M.remove_message_now(message_id) ctx.render_state:remove_message(message_id) return end - + local cached = ctx.render_state:get_message(message_id) if not cached or not cached.line_start or not cached.line_end then ctx.render_state:remove_message(message_id) return end - + output_window.clear_extmarks(cached.line_start, cached.line_end + 1) output_window.set_lines({}, cached.line_start, cached.line_end + 1) local delta = -(cached.line_end - cached.line_start + 1) @@ -805,5 +803,4 @@ function M.remove_message_now(message_id) ctx.render_state:remove_message(message_id) end - return M diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index 46e7bd0d..e250d3c0 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -3,6 +3,7 @@ local config = require('opencode.config') local ctx = require('opencode.ui.renderer.ctx') local permission_window = require('opencode.ui.permission_window') local flush = require('opencode.ui.renderer.flush') +local reference_facts = require('opencode.ui.reference_facts') ---@param message OpencodeMessage|nil ---@return string|nil @@ -49,6 +50,61 @@ local function find_message_in_state(message_id) return nil end +local function is_assistant_message(message) + return message and message.info and message.info.role == 'assistant' +end + +local function find_part_index(message, part_id) + if not message or not message.parts or not part_id then + return nil + end + for index, part in ipairs(message.parts) do + if part.id == part_id then + return index + end + end + return nil +end + +local function mark_following_assistant_text_parts_dirty(message, changed_part_index) + if not is_assistant_message(message) or not changed_part_index then + return + end + + local message_id = message.info and message.info.id + for index = changed_part_index + 1, #(message.parts or {}) do + local part = message.parts[index] + if part.type == 'text' and part.text and part.id then + flush.mark_part_dirty(part.id, message_id) + end + end +end + +local function mark_rendered_assistant_text_parts_dirty() + local active_session_id = state.active_session and state.active_session.id + if not active_session_id then + return + end + + for part_id, part_data in pairs(ctx.render_state._parts or {}) do + local part = part_data.part + if + part + and part.type == 'text' + and part.text + and not part.synthetic + and part_data.line_start + and part_data.line_end + then + local message_data = ctx.render_state:get_message(part_data.message_id) + local message = message_data and message_data.message or find_message_in_state(part_data.message_id) + if is_assistant_message(message) and message.info.sessionID == active_session_id then + flush.mark_part_dirty(part_id, part_data.message_id) + end + end + end +end + -- Lazy require to avoid circular dependency: renderer.lua <-> events.lua ---@param force? boolean local function scroll(force) @@ -57,6 +113,11 @@ end local M = {} +function M.invalidate_reference_targets_for_file_change() + reference_facts.refresh_current_files() + mark_rendered_assistant_text_parts_dirty() +end + ---@param message_id string ---@param revert_index? integer local function replay_orphan_parts(message_id, revert_index) @@ -267,6 +328,7 @@ function M.on_message_removed(properties) end end + reference_facts.remove_message(message_id) flush.queue_message_removal(message_id) for i, msg in ipairs(state.messages or {}) do @@ -384,6 +446,11 @@ function M.on_part_updated(properties, revert_index) return end + local ref_scope_changed = reference_facts.replace_part(state.active_session.id, message, part) + if ref_scope_changed then + mark_following_assistant_text_parts_dirty(message, find_part_index(message, part.id)) + end + if is_new_part then ctx.render_state:set_part(part) else @@ -458,13 +525,19 @@ function M.on_part_removed(properties) -- Remove the part from the in-memory message too local cached = ctx.render_state:get_part(part_id) - local message_id = cached and cached.message_id + local message_id = (cached and cached.message_id) or properties.messageID if message_id then local rendered_message = ctx.render_state:get_message(message_id) - if rendered_message and rendered_message.message and rendered_message.message.parts then - for i, part in ipairs(rendered_message.message.parts) do + local message = rendered_message and rendered_message.message or find_message_in_state(message_id) + local removed_index = find_part_index(message, part_id) + local ref_scope_changed = reference_facts.remove_part(message_id, part_id) + if message and message.parts then + if ref_scope_changed then + mark_following_assistant_text_parts_dirty(message, removed_index) + end + for i, part in ipairs(message.parts) do if part.id == part_id then - table.remove(rendered_message.message.parts, i) + table.remove(message.parts, i) break end end @@ -598,11 +671,17 @@ end ---@param properties {file: string} function M.on_file_edited(properties) vim.cmd('checktime') + M.invalidate_reference_targets_for_file_change() if config.hooks and config.hooks.on_file_edited then pcall(config.hooks.on_file_edited, properties.file) end end +---@param properties {file: string, event: "add"|"change"|"unlink"} +function M.on_file_watcher_updated(properties) + M.invalidate_reference_targets_for_file_change() +end + ---Handle custom.restore_point.created ---@param properties RestorePointCreatedEvent function M.on_restore_points(properties) diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index d8f22092..f5fb2bfc 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -1,6 +1,8 @@ local state = require('opencode.state') local config = require('opencode.config') local formatter = require('opencode.ui.formatter') +local reference_facts = require('opencode.ui.reference_facts') +local symbol_snapshot = require('opencode.ui.symbol_snapshot') local output_window = require('opencode.ui.output_window') local ctx = require('opencode.ui.renderer.ctx') local scroll = require('opencode.ui.renderer.scroll') @@ -296,6 +298,19 @@ local function snapshot_pending() return pending end +---@return FormatterContext +local function new_formatter_context() + return { + interactive = true, + get_child_parts = function(session_id) + return ctx.render_state:get_child_session_parts(session_id) + end, + current_refs = reference_facts.current_refs(), + current_files = reference_facts.current_files(), + symbol_cycle = symbol_snapshot.new_cycle(), + } +end + ---@param message_id string ---@return Output|nil local function format_message(message_id) @@ -319,9 +334,10 @@ local function format_message(message_id) end ---@param part_id string +---@param render_context FormatterContext ---@return Output|nil formatted ---@return string|nil message_id -local function format_part(part_id) +local function format_part(part_id, render_context) local rendered_part = ctx.render_state:get_part(part_id) if not rendered_part or not rendered_part.part then return nil @@ -334,15 +350,7 @@ local function format_part(part_id) end local is_last_part = (buffer.get_last_part_for_message(message) == part_id) - local ok, formatted_or_err = pcall( - formatter.format_part, - rendered_part.part, - message, - is_last_part, - function(session_id) - return ctx.render_state:get_child_session_parts(session_id) - end - ) + local ok, formatted_or_err = pcall(formatter.format_part, rendered_part.part, message, is_last_part, render_context) if not ok then warn_part_render_error_once(part_id, rendered_part.message_id, formatted_or_err) return nil, rendered_part.message_id @@ -363,10 +371,11 @@ end ---@param part_id string ---@param message_id string|nil -local function apply_part(part_id, message_id) +---@param render_context FormatterContext +local function apply_part(part_id, message_id, render_context) local previous = ctx.formatted_parts[part_id] local formatted = nil - formatted, message_id = format_part(part_id) + formatted, message_id = format_part(part_id, render_context) if not formatted or not message_id then return end @@ -395,8 +404,9 @@ local function apply_part(part_id, message_id) end ---@param pending RendererCtx['pending'] +---@param render_context FormatterContext ---@return boolean -local function apply_pending(pending) +local function apply_pending(pending, render_context) local buf = state.windows and state.windows.output_buf if not buf or not vim.api.nvim_buf_is_valid(buf) then return false @@ -465,7 +475,7 @@ local function apply_pending(pending) local parts = message and message.message and message.message.parts or {} for _, part in ipairs(parts or {}) do if part.id and dirty_parts[part.id] then - apply_part(part.id, message_id) + apply_part(part.id, message_id, render_context) dirty_parts[part.id] = nil pending.dirty_parts[part.id] = nil end @@ -476,7 +486,7 @@ local function apply_pending(pending) for _, part_id in ipairs(pending.dirty_part_order) do local message_id = pending.dirty_parts[part_id] if message_id then - apply_part(part_id, message_id) + apply_part(part_id, message_id, render_context) end end end) @@ -587,7 +597,7 @@ end ---Flush all pending renderer changes to the output buffer. function M.flush() local pending = snapshot_pending() - local applied = apply_pending(pending) + local applied = apply_pending(pending, new_formatter_context()) if applied and not ctx.bulk_mode then M.request_on_data_rendered() end diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index d6888759..37ef0006 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -147,7 +147,10 @@ local function format_messages(messages, omitted_count) local parts = msg.parts or {} for part_idx, part in ipairs(parts) do local is_last = part_idx == #parts - local ok, part_output = pcall(formatter.format_part, part, msg, is_last) + local ok, part_output = pcall(formatter.format_part, part, msg, is_last, { + interactive = false, + get_child_parts = nil, + }) if ok and part_output then vim.list_extend(all_lines, part_output.lines) append_extmarks(all_extmarks, part_output.extmarks, line_offset) diff --git a/lua/opencode/ui/symbol_snapshot.lua b/lua/opencode/ui/symbol_snapshot.lua index 300ee977..b01a8034 100644 --- a/lua/opencode/ui/symbol_snapshot.lua +++ b/lua/opencode/ui/symbol_snapshot.lua @@ -1,8 +1,5 @@ local M = {} --- A snapshot is a pull-time view over the files referenced by the current --- conversation. It has no lifecycle, cache, or edit subscriptions; render and --- keypress paths collect a fresh snapshot when they need one. local MIN_DEFINITION_TOKEN_LENGTH = 2 local function absolute_path(path) @@ -112,10 +109,11 @@ function M.token_variants(token) return variants end -local function collect_path(snapshot, path) +local function collect_path(path) + local by_token = {} local filetype = vim.filetype and vim.filetype.match and vim.filetype.match({ filename = path }) or nil if not filetype then - return + return by_token end local lang = filetype @@ -134,19 +132,19 @@ local function collect_path(snapshot, path) end end) if not query_ok or not query then - return + return by_token end local source, root = current_source_root(path, lang) if not (source and root) then - return + return by_token end local iter_ok, iter, iter_state, iter_initial = pcall(function() return query:iter_captures(root, source, 0, -1) end) if not iter_ok or not iter then - return + return by_token end for capture_id, node in iter, iter_state, iter_initial do @@ -157,10 +155,10 @@ local function collect_path(snapshot, path) end) if kind and kind ~= 'associated' and text_ok and definition_token(token) then local row, col = node:range() - local targets = snapshot.by_token[token] + local targets = by_token[token] if not targets then targets = {} - snapshot.by_token[token] = targets + by_token[token] = targets end table.insert(targets, { token = token, @@ -171,54 +169,55 @@ local function collect_path(snapshot, path) }) end end -end -function M.collect(refs) - local snapshot = { by_token = {} } - local seen_paths = {} - local paths = {} - - for _, ref in ipairs(refs or {}) do - if ref.file_path then - local path = absolute_path(ref.file_path) - if not seen_paths[path] and vim.fn.filereadable(path) == 1 then - seen_paths[path] = true - table.insert(paths, path) - end - end - end + return by_token +end - for _, path in ipairs(paths) do - collect_path(snapshot, path) - end +local function is_cycle(value) + return type(value) == 'table' and value._symbol_snapshot_cycle == true +end - return snapshot +function M.new_cycle() + return { + _symbol_snapshot_cycle = true, + by_path = {}, + } end -function M.has_token(snapshot, token) - if not (snapshot and snapshot.by_token) then - return false +local function collect_cycle_path(cycle, path) + local absolute = absolute_path(path) + if cycle.by_path[absolute] == nil then + cycle.by_path[absolute] = collect_path(absolute) end - - local targets = snapshot.by_token[token] - return targets ~= nil and #targets > 0 + return cycle.by_path[absolute] end -function M.targets_for_token(snapshot, token) - if not (snapshot and snapshot.by_token) then +function M.targets_for_token(cycle, token, candidate_files) + if not is_cycle(cycle) then return {} end - local targets = snapshot.by_token[token] - if not targets then + if type(token) ~= 'string' or type(candidate_files) ~= 'table' or #candidate_files == 0 then return {} end - local copy = {} - for _, target in ipairs(targets) do - table.insert(copy, target) + local targets = {} + local seen = {} + + for _, path in ipairs(candidate_files) do + local path_snapshot = collect_cycle_path(cycle, path) + for _, variant in ipairs(M.token_variants(token)) do + for _, target in ipairs(path_snapshot[variant] or {}) do + local key = table.concat({ target.path or '', target.line or 0, target.col or 0, target.token or '' }, ':') + if not seen[key] then + seen[key] = true + table.insert(targets, vim.deepcopy(target)) + end + end + end end - return copy + + return targets end return M diff --git a/tests/data/api-abort.expected.json b/tests/data/api-abort.expected.json index 151b0359..44cdec8e 100644 --- a/tests/data/api-abort.expected.json +++ b/tests/data/api-abort.expected.json @@ -194,21 +194,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 9, - 10, - 86, - { - "end_col": 99, - "end_right_gravity": false, - "end_row": 10, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -222,7 +207,7 @@ "----", "", "", - "You asked if I can generate 10 numbers, and you referenced reading an empty file ( `a-empty.txt`). However, I'm currently in \"plan mode,\" which means I cannot write or modify any files—I'm only allowed to read, observe,", + "You asked if I can generate 10 numbers, and you referenced reading an empty file (`a-empty.txt`). However, I'm currently in \"plan mode,\" which means I cannot write or modify any files—I'm only allowed to read, observe,", "", "> [!ERROR] The operation was aborted.", "", diff --git a/tests/data/cursor_data.expected.json b/tests/data/cursor_data.expected.json index cf06027c..46429f66 100644 --- a/tests/data/cursor_data.expected.json +++ b/tests/data/cursor_data.expected.json @@ -314,21 +314,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 15, - 17, - 95, - { - "end_col": 124, - "end_right_gravity": false, - "end_row": 17, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -349,7 +334,7 @@ "", "**Explanation**", "", - "- The line `local is_enabled = vim.tbl_get(config, 'context', context_key, 'enabled')` (in  `lua/opencode/context.lua:58`) uses `vim.tbl_get` to safely read a nested field from a table.", + "- The line `local is_enabled = vim.tbl_get(config, 'context', context_key, 'enabled')` (in `lua/opencode/context.lua:58`) uses `vim.tbl_get` to safely read a nested field from a table.", "- Concretely it attempts to read `config.context[context_key].enabled` but without throwing an error if `config.context` or `config.context[context_key]` is nil. If any intermediate key is missing it returns `nil`.", "- In the surrounding function `M.is_context_enabled`, that value is the default config value for the given context key. The function then checks the state override:", " - If `state.current_context_config[context_key].enabled` is not `nil`, that state value (true/false) is returned.", diff --git a/tests/data/diagnostics.expected.json b/tests/data/diagnostics.expected.json index 74953060..d07cabcb 100644 --- a/tests/data/diagnostics.expected.json +++ b/tests/data/diagnostics.expected.json @@ -336,36 +336,6 @@ ], [ 12, - 12, - 63, - { - "end_col": 86, - "end_right_gravity": false, - "end_row": 12, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 13, - 15, - 20, - { - "end_col": 45, - "end_right_gravity": false, - "end_row": 15, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 14, 39, 0, { @@ -385,7 +355,7 @@ } ], [ - 15, + 13, 40, 0, { @@ -405,7 +375,7 @@ } ], [ - 16, + 14, 41, 0, { @@ -425,7 +395,7 @@ } ], [ - 17, + 15, 42, 0, { @@ -455,7 +425,7 @@ } ], [ - 18, + 16, 42, 0, { @@ -475,7 +445,7 @@ } ], [ - 19, + 17, 43, 0, { @@ -505,7 +475,7 @@ } ], [ - 20, + 18, 43, 0, { @@ -525,7 +495,7 @@ } ], [ - 21, + 19, 44, 0, { @@ -555,7 +525,7 @@ } ], [ - 22, + 20, 44, 0, { @@ -575,7 +545,7 @@ } ], [ - 23, + 21, 45, 0, { @@ -605,7 +575,7 @@ } ], [ - 24, + 22, 45, 0, { @@ -625,7 +595,7 @@ } ], [ - 25, + 23, 46, 0, { @@ -657,7 +627,7 @@ } ], [ - 26, + 24, 46, 0, { @@ -677,7 +647,7 @@ } ], [ - 27, + 25, 47, 0, { @@ -709,7 +679,7 @@ } ], [ - 28, + 26, 47, 0, { @@ -729,7 +699,7 @@ } ], [ - 29, + 27, 48, 0, { @@ -761,7 +731,7 @@ } ], [ - 30, + 28, 48, 0, { @@ -781,7 +751,7 @@ } ], [ - 31, + 29, 49, 0, { @@ -813,7 +783,7 @@ } ], [ - 32, + 30, 49, 0, { @@ -833,7 +803,7 @@ } ], [ - 33, + 31, 50, 0, { @@ -863,7 +833,7 @@ } ], [ - 34, + 32, 50, 0, { @@ -883,7 +853,7 @@ } ], [ - 35, + 33, 51, 0, { @@ -913,7 +883,7 @@ } ], [ - 36, + 34, 51, 0, { @@ -933,7 +903,7 @@ } ], [ - 37, + 35, 52, 0, { @@ -963,7 +933,7 @@ } ], [ - 38, + 36, 52, 0, { @@ -983,7 +953,7 @@ } ], [ - 39, + 37, 53, 0, { @@ -1013,7 +983,7 @@ } ], [ - 40, + 38, 53, 0, { @@ -1033,7 +1003,7 @@ } ], [ - 41, + 39, 54, 0, { @@ -1053,7 +1023,7 @@ } ], [ - 42, + 40, 55, 0, { @@ -1073,7 +1043,7 @@ } ], [ - 43, + 41, 60, 0, { @@ -1111,7 +1081,7 @@ } ], [ - 44, + 42, 60, 0, { @@ -1130,7 +1100,7 @@ } ], [ - 45, + 43, 78, 0, { @@ -1150,7 +1120,7 @@ } ], [ - 46, + 44, 79, 0, { @@ -1170,7 +1140,7 @@ } ], [ - 47, + 45, 80, 0, { @@ -1190,7 +1160,7 @@ } ], [ - 48, + 46, 81, 0, { @@ -1220,7 +1190,7 @@ } ], [ - 49, + 47, 81, 0, { @@ -1240,7 +1210,7 @@ } ], [ - 50, + 48, 82, 0, { @@ -1270,7 +1240,7 @@ } ], [ - 51, + 49, 82, 0, { @@ -1290,7 +1260,7 @@ } ], [ - 52, + 50, 83, 0, { @@ -1320,7 +1290,7 @@ } ], [ - 53, + 51, 83, 0, { @@ -1340,7 +1310,7 @@ } ], [ - 54, + 52, 84, 0, { @@ -1370,7 +1340,7 @@ } ], [ - 55, + 53, 84, 0, { @@ -1390,7 +1360,7 @@ } ], [ - 56, + 54, 85, 0, { @@ -1422,7 +1392,7 @@ } ], [ - 57, + 55, 85, 0, { @@ -1442,7 +1412,7 @@ } ], [ - 58, + 56, 86, 0, { @@ -1474,7 +1444,7 @@ } ], [ - 59, + 57, 86, 0, { @@ -1494,7 +1464,7 @@ } ], [ - 60, + 58, 87, 0, { @@ -1524,7 +1494,7 @@ } ], [ - 61, + 59, 87, 0, { @@ -1544,7 +1514,7 @@ } ], [ - 62, + 60, 88, 0, { @@ -1574,7 +1544,7 @@ } ], [ - 63, + 61, 88, 0, { @@ -1594,7 +1564,7 @@ } ], [ - 64, + 62, 89, 0, { @@ -1624,7 +1594,7 @@ } ], [ - 65, + 63, 89, 0, { @@ -1644,7 +1614,7 @@ } ], [ - 66, + 64, 90, 0, { @@ -1674,7 +1644,7 @@ } ], [ - 67, + 65, 90, 0, { @@ -1694,7 +1664,7 @@ } ], [ - 68, + 66, 91, 0, { @@ -1714,7 +1684,7 @@ } ], [ - 69, + 67, 92, 0, { @@ -1734,7 +1704,7 @@ } ], [ - 70, + 68, 97, 0, { @@ -1772,7 +1742,7 @@ } ], [ - 71, + 69, 97, 0, { @@ -1791,7 +1761,7 @@ } ], [ - 72, + 70, 105, 0, { @@ -1811,7 +1781,7 @@ } ], [ - 73, + 71, 106, 0, { @@ -1831,7 +1801,7 @@ } ], [ - 74, + 72, 107, 0, { @@ -1851,7 +1821,7 @@ } ], [ - 75, + 73, 108, 0, { @@ -1871,7 +1841,7 @@ } ], [ - 76, + 74, 109, 0, { @@ -1891,7 +1861,7 @@ } ], [ - 77, + 75, 110, 0, { @@ -1911,7 +1881,7 @@ } ], [ - 78, + 76, 111, 0, { @@ -1931,7 +1901,7 @@ } ], [ - 79, + 77, 112, 0, { @@ -1951,7 +1921,7 @@ } ], [ - 80, + 78, 113, 0, { @@ -1971,7 +1941,7 @@ } ], [ - 81, + 79, 114, 0, { @@ -1991,7 +1961,7 @@ } ], [ - 82, + 80, 115, 0, { @@ -2011,7 +1981,7 @@ } ], [ - 83, + 81, 116, 0, { @@ -2031,7 +2001,7 @@ } ], [ - 84, + 82, 117, 0, { @@ -2051,7 +2021,7 @@ } ], [ - 85, + 83, 118, 0, { @@ -2071,7 +2041,7 @@ } ], [ - 86, + 84, 119, 0, { @@ -2091,7 +2061,7 @@ } ], [ - 87, + 85, 120, 0, { @@ -2111,7 +2081,7 @@ } ], [ - 88, + 86, 121, 0, { @@ -2131,7 +2101,7 @@ } ], [ - 89, + 87, 122, 0, { @@ -2151,7 +2121,7 @@ } ], [ - 90, + 88, 123, 0, { @@ -2171,7 +2141,7 @@ } ], [ - 91, + 89, 124, 0, { @@ -2191,7 +2161,7 @@ } ], [ - 92, + 90, 125, 0, { @@ -2211,7 +2181,7 @@ } ], [ - 93, + 91, 126, 0, { @@ -2231,7 +2201,7 @@ } ], [ - 94, + 92, 127, 0, { @@ -2251,7 +2221,7 @@ } ], [ - 95, + 93, 128, 0, { @@ -2271,7 +2241,7 @@ } ], [ - 96, + 94, 129, 0, { @@ -2291,7 +2261,7 @@ } ], [ - 97, + 95, 130, 0, { @@ -2311,7 +2281,7 @@ } ], [ - 98, + 96, 131, 0, { @@ -2331,7 +2301,7 @@ } ], [ - 99, + 97, 132, 0, { @@ -2351,7 +2321,7 @@ } ], [ - 100, + 98, 133, 0, { @@ -2371,7 +2341,7 @@ } ], [ - 101, + 99, 134, 0, { @@ -2391,7 +2361,7 @@ } ], [ - 102, + 100, 135, 0, { @@ -2411,7 +2381,7 @@ } ], [ - 103, + 101, 136, 0, { @@ -2431,7 +2401,7 @@ } ], [ - 104, + 102, 137, 0, { @@ -2451,7 +2421,7 @@ } ], [ - 105, + 103, 138, 0, { @@ -2471,7 +2441,7 @@ } ], [ - 106, + 104, 139, 0, { @@ -2491,7 +2461,7 @@ } ], [ - 107, + 105, 140, 0, { @@ -2511,7 +2481,7 @@ } ], [ - 108, + 106, 141, 0, { @@ -2531,7 +2501,7 @@ } ], [ - 109, + 107, 142, 0, { @@ -2551,7 +2521,7 @@ } ], [ - 110, + 108, 143, 0, { @@ -2571,7 +2541,7 @@ } ], [ - 111, + 109, 144, 0, { @@ -2591,7 +2561,7 @@ } ], [ - 112, + 110, 145, 0, { @@ -2611,7 +2581,7 @@ } ], [ - 113, + 111, 146, 0, { @@ -2631,7 +2601,7 @@ } ], [ - 114, + 112, 147, 0, { @@ -2651,7 +2621,7 @@ } ], [ - 115, + 113, 148, 0, { @@ -2671,7 +2641,7 @@ } ], [ - 116, + 114, 149, 0, { @@ -2691,7 +2661,7 @@ } ], [ - 117, + 115, 150, 0, { @@ -2711,7 +2681,7 @@ } ], [ - 118, + 116, 151, 0, { @@ -2731,7 +2701,7 @@ } ], [ - 119, + 117, 152, 0, { @@ -2751,7 +2721,7 @@ } ], [ - 120, + 118, 153, 0, { @@ -2771,7 +2741,7 @@ } ], [ - 121, + 119, 154, 0, { @@ -2791,7 +2761,7 @@ } ], [ - 122, + 120, 155, 0, { @@ -2811,7 +2781,7 @@ } ], [ - 123, + 121, 156, 0, { @@ -2831,7 +2801,7 @@ } ], [ - 124, + 122, 157, 0, { @@ -2851,7 +2821,7 @@ } ], [ - 125, + 123, 158, 0, { @@ -2871,7 +2841,7 @@ } ], [ - 126, + 124, 159, 0, { @@ -2891,7 +2861,7 @@ } ], [ - 127, + 125, 160, 0, { @@ -2911,7 +2881,7 @@ } ], [ - 128, + 126, 161, 0, { @@ -2931,7 +2901,7 @@ } ], [ - 129, + 127, 162, 0, { @@ -2951,7 +2921,7 @@ } ], [ - 130, + 128, 163, 0, { @@ -2971,7 +2941,7 @@ } ], [ - 131, + 129, 164, 0, { @@ -2991,7 +2961,7 @@ } ], [ - 132, + 130, 165, 0, { @@ -3011,7 +2981,7 @@ } ], [ - 133, + 131, 166, 0, { @@ -3031,7 +3001,7 @@ } ], [ - 134, + 132, 167, 0, { @@ -3051,7 +3021,7 @@ } ], [ - 135, + 133, 168, 0, { @@ -3071,7 +3041,7 @@ } ], [ - 136, + 134, 169, 0, { @@ -3091,7 +3061,7 @@ } ], [ - 137, + 135, 170, 0, { @@ -3111,7 +3081,7 @@ } ], [ - 138, + 136, 171, 0, { @@ -3131,7 +3101,7 @@ } ], [ - 139, + 137, 172, 0, { @@ -3151,7 +3121,7 @@ } ], [ - 140, + 138, 173, 0, { @@ -3171,7 +3141,7 @@ } ], [ - 141, + 139, 174, 0, { @@ -3191,7 +3161,7 @@ } ], [ - 142, + 140, 175, 0, { @@ -3211,7 +3181,7 @@ } ], [ - 143, + 141, 176, 0, { @@ -3231,7 +3201,7 @@ } ], [ - 144, + 142, 177, 0, { @@ -3251,7 +3221,7 @@ } ], [ - 145, + 143, 178, 0, { @@ -3271,7 +3241,7 @@ } ], [ - 146, + 144, 179, 0, { @@ -3291,7 +3261,7 @@ } ], [ - 147, + 145, 180, 0, { @@ -3311,7 +3281,7 @@ } ], [ - 148, + 146, 181, 0, { @@ -3331,7 +3301,7 @@ } ], [ - 149, + 147, 182, 0, { @@ -3351,7 +3321,7 @@ } ], [ - 150, + 148, 183, 0, { @@ -3371,7 +3341,7 @@ } ], [ - 151, + 149, 184, 0, { @@ -3391,7 +3361,7 @@ } ], [ - 152, + 150, 185, 0, { @@ -3411,7 +3381,7 @@ } ], [ - 153, + 151, 186, 0, { @@ -3431,7 +3401,7 @@ } ], [ - 154, + 152, 187, 0, { @@ -3451,7 +3421,7 @@ } ], [ - 155, + 153, 188, 0, { @@ -3471,7 +3441,7 @@ } ], [ - 156, + 154, 189, 0, { @@ -3491,7 +3461,7 @@ } ], [ - 157, + 155, 190, 0, { @@ -3511,7 +3481,7 @@ } ], [ - 158, + 156, 191, 0, { @@ -3531,7 +3501,7 @@ } ], [ - 159, + 157, 192, 0, { @@ -3551,7 +3521,7 @@ } ], [ - 160, + 158, 193, 0, { @@ -3571,7 +3541,7 @@ } ], [ - 161, + 159, 194, 0, { @@ -3591,7 +3561,7 @@ } ], [ - 162, + 160, 195, 0, { @@ -3611,7 +3581,7 @@ } ], [ - 163, + 161, 196, 0, { @@ -3631,7 +3601,7 @@ } ], [ - 164, + 162, 197, 0, { @@ -3651,7 +3621,7 @@ } ], [ - 165, + 163, 198, 0, { @@ -3671,7 +3641,7 @@ } ], [ - 166, + 164, 199, 0, { @@ -3691,7 +3661,7 @@ } ], [ - 167, + 165, 200, 0, { @@ -3711,7 +3681,7 @@ } ], [ - 168, + 166, 201, 0, { @@ -3731,7 +3701,7 @@ } ], [ - 169, + 167, 202, 0, { @@ -3751,7 +3721,7 @@ } ], [ - 170, + 168, 203, 0, { @@ -3771,7 +3741,7 @@ } ], [ - 171, + 169, 204, 0, { @@ -3791,7 +3761,7 @@ } ], [ - 172, + 170, 205, 0, { @@ -3811,7 +3781,7 @@ } ], [ - 173, + 171, 206, 0, { @@ -3831,7 +3801,7 @@ } ], [ - 174, + 172, 207, 0, { @@ -3851,7 +3821,7 @@ } ], [ - 175, + 173, 208, 0, { @@ -3871,7 +3841,7 @@ } ], [ - 176, + 174, 209, 0, { @@ -3891,7 +3861,7 @@ } ], [ - 177, + 175, 210, 0, { @@ -3911,7 +3881,7 @@ } ], [ - 178, + 176, 211, 0, { @@ -3931,7 +3901,7 @@ } ], [ - 179, + 177, 212, 0, { @@ -3951,7 +3921,7 @@ } ], [ - 180, + 178, 213, 0, { @@ -3971,7 +3941,7 @@ } ], [ - 181, + 179, 214, 0, { @@ -3991,7 +3961,7 @@ } ], [ - 182, + 180, 215, 0, { @@ -4011,7 +3981,7 @@ } ], [ - 183, + 181, 216, 0, { @@ -4031,7 +4001,7 @@ } ], [ - 184, + 182, 217, 0, { @@ -4051,7 +4021,7 @@ } ], [ - 185, + 183, 218, 0, { @@ -4071,7 +4041,7 @@ } ], [ - 186, + 184, 219, 0, { @@ -4091,7 +4061,7 @@ } ], [ - 187, + 185, 220, 0, { @@ -4111,7 +4081,7 @@ } ], [ - 188, + 186, 221, 0, { @@ -4131,7 +4101,7 @@ } ], [ - 189, + 187, 222, 0, { @@ -4151,7 +4121,7 @@ } ], [ - 190, + 188, 223, 0, { @@ -4171,7 +4141,7 @@ } ], [ - 191, + 189, 224, 0, { @@ -4191,7 +4161,7 @@ } ], [ - 192, + 190, 225, 0, { @@ -4211,7 +4181,7 @@ } ], [ - 193, + 191, 226, 0, { @@ -4231,7 +4201,7 @@ } ], [ - 194, + 192, 227, 0, { @@ -4251,7 +4221,7 @@ } ], [ - 195, + 193, 228, 0, { @@ -4271,7 +4241,7 @@ } ], [ - 196, + 194, 229, 0, { @@ -4291,7 +4261,7 @@ } ], [ - 197, + 195, 230, 0, { @@ -4311,7 +4281,7 @@ } ], [ - 198, + 196, 231, 0, { @@ -4331,7 +4301,7 @@ } ], [ - 199, + 197, 232, 0, { @@ -4351,7 +4321,7 @@ } ], [ - 200, + 198, 233, 0, { @@ -4371,7 +4341,7 @@ } ], [ - 201, + 199, 234, 0, { @@ -4391,7 +4361,7 @@ } ], [ - 202, + 200, 235, 0, { @@ -4411,7 +4381,7 @@ } ], [ - 203, + 201, 236, 0, { @@ -4431,7 +4401,7 @@ } ], [ - 204, + 202, 237, 0, { @@ -4451,7 +4421,7 @@ } ], [ - 205, + 203, 238, 0, { @@ -4471,7 +4441,7 @@ } ], [ - 206, + 204, 239, 0, { @@ -4491,7 +4461,7 @@ } ], [ - 207, + 205, 240, 0, { @@ -4511,7 +4481,7 @@ } ], [ - 208, + 206, 241, 0, { @@ -4531,7 +4501,7 @@ } ], [ - 209, + 207, 242, 0, { @@ -4551,7 +4521,7 @@ } ], [ - 210, + 208, 243, 0, { @@ -4571,7 +4541,7 @@ } ], [ - 211, + 209, 244, 0, { @@ -4591,7 +4561,7 @@ } ], [ - 212, + 210, 245, 0, { @@ -4611,7 +4581,7 @@ } ], [ - 213, + 211, 246, 0, { @@ -4631,7 +4601,7 @@ } ], [ - 214, + 212, 247, 0, { @@ -4651,7 +4621,7 @@ } ], [ - 215, + 213, 248, 0, { @@ -4671,7 +4641,7 @@ } ], [ - 216, + 214, 249, 0, { @@ -4691,7 +4661,7 @@ } ], [ - 217, + 215, 250, 0, { @@ -4711,7 +4681,7 @@ } ], [ - 218, + 216, 251, 0, { @@ -4731,7 +4701,7 @@ } ], [ - 219, + 217, 252, 0, { @@ -4751,7 +4721,7 @@ } ], [ - 220, + 218, 253, 0, { @@ -4771,7 +4741,7 @@ } ], [ - 221, + 219, 254, 0, { @@ -4791,7 +4761,7 @@ } ], [ - 222, + 220, 255, 0, { @@ -4811,7 +4781,7 @@ } ], [ - 223, + 221, 256, 0, { @@ -4831,7 +4801,7 @@ } ], [ - 224, + 222, 257, 0, { @@ -4851,7 +4821,7 @@ } ], [ - 225, + 223, 258, 0, { @@ -4871,7 +4841,7 @@ } ], [ - 226, + 224, 259, 0, { @@ -4891,7 +4861,7 @@ } ], [ - 227, + 225, 260, 0, { @@ -4911,7 +4881,7 @@ } ], [ - 228, + 226, 261, 0, { @@ -4931,7 +4901,7 @@ } ], [ - 229, + 227, 262, 0, { @@ -4951,7 +4921,7 @@ } ], [ - 230, + 228, 263, 0, { @@ -4971,7 +4941,7 @@ } ], [ - 231, + 229, 264, 0, { @@ -4991,7 +4961,7 @@ } ], [ - 232, + 230, 265, 0, { @@ -5011,7 +4981,7 @@ } ], [ - 233, + 231, 266, 0, { @@ -5031,7 +5001,7 @@ } ], [ - 234, + 232, 267, 0, { @@ -5051,7 +5021,7 @@ } ], [ - 235, + 233, 268, 0, { @@ -5071,7 +5041,7 @@ } ], [ - 236, + 234, 269, 0, { @@ -5091,7 +5061,7 @@ } ], [ - 237, + 235, 270, 0, { @@ -5111,7 +5081,7 @@ } ], [ - 238, + 236, 271, 0, { @@ -5131,7 +5101,7 @@ } ], [ - 239, + 237, 272, 0, { @@ -5151,7 +5121,7 @@ } ], [ - 240, + 238, 273, 0, { @@ -5171,7 +5141,7 @@ } ], [ - 241, + 239, 274, 0, { @@ -5191,7 +5161,7 @@ } ], [ - 242, + 240, 275, 0, { @@ -5211,7 +5181,7 @@ } ], [ - 243, + 241, 276, 0, { @@ -5231,7 +5201,7 @@ } ], [ - 244, + 242, 277, 0, { @@ -5251,7 +5221,7 @@ } ], [ - 245, + 243, 278, 0, { @@ -5271,7 +5241,7 @@ } ], [ - 246, + 244, 279, 0, { @@ -5291,7 +5261,7 @@ } ], [ - 247, + 245, 280, 0, { @@ -5311,7 +5281,7 @@ } ], [ - 248, + 246, 281, 0, { @@ -5331,7 +5301,7 @@ } ], [ - 249, + 247, 282, 0, { @@ -5351,7 +5321,7 @@ } ], [ - 250, + 248, 283, 0, { @@ -5371,7 +5341,7 @@ } ], [ - 251, + 249, 284, 0, { @@ -5391,7 +5361,7 @@ } ], [ - 252, + 250, 285, 0, { @@ -5411,7 +5381,7 @@ } ], [ - 253, + 251, 286, 0, { @@ -5431,7 +5401,7 @@ } ], [ - 254, + 252, 287, 0, { @@ -5451,7 +5421,7 @@ } ], [ - 255, + 253, 288, 0, { @@ -5471,7 +5441,7 @@ } ], [ - 256, + 254, 289, 0, { @@ -5491,7 +5461,7 @@ } ], [ - 257, + 255, 290, 0, { @@ -5511,7 +5481,7 @@ } ], [ - 258, + 256, 291, 0, { @@ -5531,7 +5501,7 @@ } ], [ - 259, + 257, 292, 0, { @@ -5551,7 +5521,7 @@ } ], [ - 260, + 258, 293, 0, { @@ -5571,7 +5541,7 @@ } ], [ - 261, + 259, 294, 0, { @@ -5591,7 +5561,7 @@ } ], [ - 262, + 260, 295, 0, { @@ -5611,7 +5581,7 @@ } ], [ - 263, + 261, 296, 0, { @@ -5631,7 +5601,7 @@ } ], [ - 264, + 262, 297, 0, { @@ -5651,7 +5621,7 @@ } ], [ - 265, + 263, 298, 0, { @@ -5671,7 +5641,7 @@ } ], [ - 266, + 264, 299, 0, { @@ -5691,7 +5661,7 @@ } ], [ - 267, + 265, 300, 0, { @@ -5711,7 +5681,7 @@ } ], [ - 268, + 266, 301, 0, { @@ -5731,7 +5701,7 @@ } ], [ - 269, + 267, 302, 0, { @@ -5751,7 +5721,7 @@ } ], [ - 270, + 268, 303, 0, { @@ -5771,7 +5741,7 @@ } ], [ - 271, + 269, 304, 0, { @@ -5791,7 +5761,7 @@ } ], [ - 272, + 270, 305, 0, { @@ -5811,7 +5781,7 @@ } ], [ - 273, + 271, 306, 0, { @@ -5831,7 +5801,7 @@ } ], [ - 274, + 272, 307, 0, { @@ -5851,7 +5821,7 @@ } ], [ - 275, + 273, 308, 0, { @@ -5871,7 +5841,7 @@ } ], [ - 276, + 274, 309, 0, { @@ -5891,7 +5861,7 @@ } ], [ - 277, + 275, 310, 0, { @@ -5911,7 +5881,7 @@ } ], [ - 278, + 276, 311, 0, { @@ -5931,7 +5901,7 @@ } ], [ - 279, + 277, 312, 0, { @@ -5951,7 +5921,7 @@ } ], [ - 280, + 278, 313, 0, { @@ -5971,7 +5941,7 @@ } ], [ - 281, + 279, 314, 0, { @@ -5991,7 +5961,7 @@ } ], [ - 282, + 280, 315, 0, { @@ -6011,7 +5981,7 @@ } ], [ - 283, + 281, 316, 0, { @@ -6031,7 +6001,7 @@ } ], [ - 284, + 282, 317, 0, { @@ -6051,7 +6021,7 @@ } ], [ - 285, + 283, 318, 0, { @@ -6071,7 +6041,7 @@ } ], [ - 286, + 284, 319, 0, { @@ -6091,7 +6061,7 @@ } ], [ - 287, + 285, 320, 0, { @@ -6111,7 +6081,7 @@ } ], [ - 288, + 286, 321, 0, { @@ -6131,7 +6101,7 @@ } ], [ - 289, + 287, 322, 0, { @@ -6151,7 +6121,7 @@ } ], [ - 290, + 288, 323, 0, { @@ -6171,7 +6141,7 @@ } ], [ - 291, + 289, 324, 0, { @@ -6191,7 +6161,7 @@ } ], [ - 292, + 290, 325, 0, { @@ -6211,7 +6181,7 @@ } ], [ - 293, + 291, 326, 0, { @@ -6231,7 +6201,7 @@ } ], [ - 294, + 292, 327, 0, { @@ -6251,7 +6221,7 @@ } ], [ - 295, + 293, 328, 0, { @@ -6271,7 +6241,7 @@ } ], [ - 296, + 294, 329, 0, { @@ -6291,7 +6261,7 @@ } ], [ - 297, + 295, 330, 0, { @@ -6311,7 +6281,7 @@ } ], [ - 298, + 296, 331, 0, { @@ -6331,7 +6301,7 @@ } ], [ - 299, + 297, 332, 0, { @@ -6351,7 +6321,7 @@ } ], [ - 300, + 298, 333, 0, { @@ -6371,7 +6341,7 @@ } ], [ - 301, + 299, 334, 0, { @@ -6391,7 +6361,7 @@ } ], [ - 302, + 300, 335, 0, { @@ -6411,7 +6381,7 @@ } ], [ - 303, + 301, 336, 0, { @@ -6431,7 +6401,7 @@ } ], [ - 304, + 302, 337, 0, { @@ -6451,7 +6421,7 @@ } ], [ - 305, + 303, 338, 0, { @@ -6471,7 +6441,7 @@ } ], [ - 306, + 304, 339, 0, { @@ -6491,7 +6461,7 @@ } ], [ - 307, + 305, 340, 0, { @@ -6511,7 +6481,7 @@ } ], [ - 308, + 306, 341, 0, { @@ -6531,7 +6501,7 @@ } ], [ - 309, + 307, 342, 0, { @@ -6551,7 +6521,7 @@ } ], [ - 310, + 308, 343, 0, { @@ -6571,7 +6541,7 @@ } ], [ - 311, + 309, 344, 0, { @@ -6591,7 +6561,7 @@ } ], [ - 312, + 310, 345, 0, { @@ -6611,7 +6581,7 @@ } ], [ - 313, + 311, 346, 0, { @@ -6631,7 +6601,7 @@ } ], [ - 314, + 312, 347, 0, { @@ -6651,7 +6621,7 @@ } ], [ - 315, + 313, 348, 0, { @@ -6671,7 +6641,7 @@ } ], [ - 316, + 314, 349, 0, { @@ -6691,7 +6661,7 @@ } ], [ - 317, + 315, 350, 0, { @@ -6711,7 +6681,7 @@ } ], [ - 318, + 316, 351, 0, { @@ -6731,7 +6701,7 @@ } ], [ - 319, + 317, 352, 0, { @@ -6751,7 +6721,7 @@ } ], [ - 320, + 318, 353, 0, { @@ -6771,7 +6741,7 @@ } ], [ - 321, + 319, 354, 0, { @@ -6791,7 +6761,7 @@ } ], [ - 322, + 320, 355, 0, { @@ -6811,7 +6781,7 @@ } ], [ - 323, + 321, 356, 0, { @@ -6831,7 +6801,7 @@ } ], [ - 324, + 322, 357, 0, { @@ -6851,7 +6821,7 @@ } ], [ - 325, + 323, 358, 0, { @@ -6871,7 +6841,7 @@ } ], [ - 326, + 324, 359, 0, { @@ -6891,7 +6861,7 @@ } ], [ - 327, + 325, 360, 0, { @@ -6911,7 +6881,7 @@ } ], [ - 328, + 326, 361, 0, { @@ -6931,7 +6901,7 @@ } ], [ - 329, + 327, 362, 0, { @@ -6951,7 +6921,7 @@ } ], [ - 330, + 328, 363, 0, { @@ -6971,7 +6941,7 @@ } ], [ - 331, + 329, 364, 0, { @@ -6991,7 +6961,7 @@ } ], [ - 332, + 330, 365, 0, { @@ -7011,7 +6981,7 @@ } ], [ - 333, + 331, 366, 0, { @@ -7031,7 +7001,7 @@ } ], [ - 334, + 332, 367, 0, { @@ -7051,7 +7021,7 @@ } ], [ - 335, + 333, 368, 0, { @@ -7071,7 +7041,7 @@ } ], [ - 336, + 334, 369, 0, { @@ -7091,7 +7061,7 @@ } ], [ - 337, + 335, 370, 0, { @@ -7111,7 +7081,7 @@ } ], [ - 338, + 336, 371, 0, { @@ -7131,7 +7101,7 @@ } ], [ - 339, + 337, 372, 0, { @@ -7151,7 +7121,7 @@ } ], [ - 340, + 338, 373, 0, { @@ -7171,7 +7141,7 @@ } ], [ - 341, + 339, 374, 0, { @@ -7191,7 +7161,7 @@ } ], [ - 342, + 340, 375, 0, { @@ -7211,7 +7181,7 @@ } ], [ - 343, + 341, 376, 0, { @@ -7231,7 +7201,7 @@ } ], [ - 344, + 342, 377, 0, { @@ -7251,7 +7221,7 @@ } ], [ - 345, + 343, 378, 0, { @@ -7271,7 +7241,7 @@ } ], [ - 346, + 344, 379, 0, { @@ -7291,7 +7261,7 @@ } ], [ - 347, + 345, 380, 0, { @@ -7311,7 +7281,7 @@ } ], [ - 348, + 346, 381, 0, { @@ -7331,7 +7301,7 @@ } ], [ - 349, + 347, 382, 0, { @@ -7351,7 +7321,7 @@ } ], [ - 350, + 348, 383, 0, { @@ -7371,7 +7341,7 @@ } ], [ - 351, + 349, 384, 0, { @@ -7391,7 +7361,7 @@ } ], [ - 352, + 350, 385, 0, { @@ -7411,7 +7381,7 @@ } ], [ - 353, + 351, 386, 0, { @@ -7431,7 +7401,7 @@ } ], [ - 354, + 352, 387, 0, { @@ -7451,7 +7421,7 @@ } ], [ - 355, + 353, 388, 0, { @@ -7471,7 +7441,7 @@ } ], [ - 356, + 354, 389, 0, { @@ -7491,7 +7461,7 @@ } ], [ - 357, + 355, 390, 0, { @@ -7511,7 +7481,7 @@ } ], [ - 358, + 356, 391, 0, { @@ -7531,7 +7501,7 @@ } ], [ - 359, + 357, 392, 0, { @@ -7551,7 +7521,7 @@ } ], [ - 360, + 358, 393, 0, { @@ -7571,7 +7541,7 @@ } ], [ - 361, + 359, 394, 0, { @@ -7591,7 +7561,7 @@ } ], [ - 362, + 360, 395, 0, { @@ -7611,7 +7581,7 @@ } ], [ - 363, + 361, 396, 0, { @@ -7631,7 +7601,7 @@ } ], [ - 364, + 362, 397, 0, { @@ -7651,7 +7621,7 @@ } ], [ - 365, + 363, 398, 0, { @@ -7671,7 +7641,7 @@ } ], [ - 366, + 364, 399, 0, { @@ -7691,7 +7661,7 @@ } ], [ - 367, + 365, 400, 0, { @@ -7711,7 +7681,7 @@ } ], [ - 368, + 366, 401, 0, { @@ -7731,7 +7701,7 @@ } ], [ - 369, + 367, 402, 0, { @@ -7751,7 +7721,7 @@ } ], [ - 370, + 368, 403, 0, { @@ -7771,7 +7741,7 @@ } ], [ - 371, + 369, 404, 0, { @@ -7791,7 +7761,7 @@ } ], [ - 372, + 370, 405, 0, { @@ -7811,7 +7781,7 @@ } ], [ - 373, + 371, 406, 0, { @@ -7831,7 +7801,7 @@ } ], [ - 374, + 372, 407, 0, { @@ -7851,7 +7821,7 @@ } ], [ - 375, + 373, 408, 0, { @@ -7871,7 +7841,7 @@ } ], [ - 376, + 374, 409, 0, { @@ -7891,7 +7861,7 @@ } ], [ - 377, + 375, 410, 0, { @@ -7911,7 +7881,7 @@ } ], [ - 378, + 376, 411, 0, { @@ -7931,7 +7901,7 @@ } ], [ - 379, + 377, 412, 0, { @@ -7951,7 +7921,7 @@ } ], [ - 380, + 378, 413, 0, { @@ -7971,7 +7941,7 @@ } ], [ - 381, + 379, 414, 0, { @@ -7991,7 +7961,7 @@ } ], [ - 382, + 380, 415, 0, { @@ -8011,7 +7981,7 @@ } ], [ - 383, + 381, 416, 0, { @@ -8031,7 +8001,7 @@ } ], [ - 384, + 382, 417, 0, { @@ -8051,7 +8021,7 @@ } ], [ - 385, + 383, 418, 0, { @@ -8071,7 +8041,7 @@ } ], [ - 386, + 384, 419, 0, { @@ -8091,7 +8061,7 @@ } ], [ - 387, + 385, 420, 0, { @@ -8111,7 +8081,7 @@ } ], [ - 388, + 386, 421, 0, { @@ -8131,7 +8101,7 @@ } ], [ - 389, + 387, 422, 0, { @@ -8151,7 +8121,7 @@ } ], [ - 390, + 388, 423, 0, { @@ -8171,7 +8141,7 @@ } ], [ - 391, + 389, 424, 0, { @@ -8191,7 +8161,7 @@ } ], [ - 392, + 390, 425, 0, { @@ -8211,7 +8181,7 @@ } ], [ - 393, + 391, 426, 0, { @@ -8231,7 +8201,7 @@ } ], [ - 394, + 392, 427, 0, { @@ -8251,7 +8221,7 @@ } ], [ - 395, + 393, 428, 0, { @@ -8271,7 +8241,7 @@ } ], [ - 396, + 394, 429, 0, { @@ -8291,7 +8261,7 @@ } ], [ - 397, + 395, 430, 0, { @@ -8311,7 +8281,7 @@ } ], [ - 398, + 396, 431, 0, { @@ -8331,7 +8301,7 @@ } ], [ - 399, + 397, 432, 0, { @@ -8351,7 +8321,7 @@ } ], [ - 400, + 398, 433, 0, { @@ -8371,7 +8341,7 @@ } ], [ - 401, + 399, 434, 0, { @@ -8391,7 +8361,7 @@ } ], [ - 402, + 400, 435, 0, { @@ -8411,7 +8381,7 @@ } ], [ - 403, + 401, 436, 0, { @@ -8431,7 +8401,7 @@ } ], [ - 404, + 402, 437, 0, { @@ -8451,7 +8421,7 @@ } ], [ - 405, + 403, 438, 0, { @@ -8471,7 +8441,7 @@ } ], [ - 406, + 404, 439, 0, { @@ -8491,7 +8461,7 @@ } ], [ - 407, + 405, 440, 0, { @@ -8511,7 +8481,7 @@ } ], [ - 408, + 406, 441, 0, { @@ -8531,7 +8501,7 @@ } ], [ - 409, + 407, 442, 0, { @@ -8551,7 +8521,7 @@ } ], [ - 410, + 408, 443, 0, { @@ -8571,7 +8541,7 @@ } ], [ - 411, + 409, 444, 0, { @@ -8591,7 +8561,7 @@ } ], [ - 412, + 410, 445, 0, { @@ -8611,7 +8581,7 @@ } ], [ - 413, + 411, 446, 0, { @@ -8631,7 +8601,7 @@ } ], [ - 414, + 412, 447, 0, { @@ -8651,7 +8621,7 @@ } ], [ - 415, + 413, 448, 0, { @@ -8671,7 +8641,7 @@ } ], [ - 416, + 414, 449, 0, { @@ -8691,7 +8661,7 @@ } ], [ - 417, + 415, 450, 0, { @@ -8711,7 +8681,7 @@ } ], [ - 418, + 416, 451, 0, { @@ -8731,7 +8701,7 @@ } ], [ - 419, + 417, 452, 0, { @@ -8751,7 +8721,7 @@ } ], [ - 420, + 418, 453, 0, { @@ -8771,7 +8741,7 @@ } ], [ - 421, + 419, 454, 0, { @@ -8791,7 +8761,7 @@ } ], [ - 422, + 420, 455, 0, { @@ -8811,7 +8781,7 @@ } ], [ - 423, + 421, 456, 0, { @@ -8831,7 +8801,7 @@ } ], [ - 424, + 422, 457, 0, { @@ -8851,7 +8821,7 @@ } ], [ - 425, + 423, 458, 0, { @@ -8871,7 +8841,7 @@ } ], [ - 426, + 424, 459, 0, { @@ -8891,7 +8861,7 @@ } ], [ - 427, + 425, 460, 0, { @@ -8911,7 +8881,7 @@ } ], [ - 428, + 426, 461, 0, { @@ -8931,7 +8901,7 @@ } ], [ - 429, + 427, 462, 0, { @@ -8951,7 +8921,7 @@ } ], [ - 430, + 428, 463, 0, { @@ -8971,7 +8941,7 @@ } ], [ - 431, + 429, 464, 0, { @@ -8991,7 +8961,7 @@ } ], [ - 432, + 430, 465, 0, { @@ -9011,7 +8981,7 @@ } ], [ - 433, + 431, 466, 0, { @@ -9031,7 +9001,7 @@ } ], [ - 434, + 432, 467, 0, { @@ -9051,7 +9021,7 @@ } ], [ - 435, + 433, 468, 0, { @@ -9071,7 +9041,7 @@ } ], [ - 436, + 434, 469, 0, { @@ -9091,7 +9061,7 @@ } ], [ - 437, + 435, 470, 0, { @@ -9111,7 +9081,7 @@ } ], [ - 438, + 436, 471, 0, { @@ -9131,7 +9101,7 @@ } ], [ - 439, + 437, 472, 0, { @@ -9151,7 +9121,7 @@ } ], [ - 440, + 438, 473, 0, { @@ -9171,7 +9141,7 @@ } ], [ - 441, + 439, 474, 0, { @@ -9191,7 +9161,7 @@ } ], [ - 442, + 440, 475, 0, { @@ -9211,7 +9181,7 @@ } ], [ - 443, + 441, 476, 0, { @@ -9231,7 +9201,7 @@ } ], [ - 444, + 442, 477, 0, { @@ -9251,7 +9221,7 @@ } ], [ - 445, + 443, 478, 0, { @@ -9271,7 +9241,7 @@ } ], [ - 446, + 444, 479, 0, { @@ -9291,7 +9261,7 @@ } ], [ - 447, + 445, 480, 0, { @@ -9311,7 +9281,7 @@ } ], [ - 448, + 446, 481, 0, { @@ -9331,7 +9301,7 @@ } ], [ - 449, + 447, 482, 0, { @@ -9351,7 +9321,7 @@ } ], [ - 450, + 448, 483, 0, { @@ -9371,7 +9341,7 @@ } ], [ - 451, + 449, 484, 0, { @@ -9391,7 +9361,7 @@ } ], [ - 452, + 450, 485, 0, { @@ -9411,7 +9381,7 @@ } ], [ - 453, + 451, 486, 0, { @@ -9431,7 +9401,7 @@ } ], [ - 454, + 452, 487, 0, { @@ -9451,7 +9421,7 @@ } ], [ - 455, + 453, 488, 0, { @@ -9471,7 +9441,7 @@ } ], [ - 456, + 454, 489, 0, { @@ -9491,7 +9461,7 @@ } ], [ - 457, + 455, 490, 0, { @@ -9511,7 +9481,7 @@ } ], [ - 458, + 456, 491, 0, { @@ -9531,7 +9501,7 @@ } ], [ - 459, + 457, 492, 0, { @@ -9551,7 +9521,7 @@ } ], [ - 460, + 458, 493, 0, { @@ -9571,7 +9541,7 @@ } ], [ - 461, + 459, 494, 0, { @@ -9591,7 +9561,7 @@ } ], [ - 462, + 460, 495, 0, { @@ -9611,7 +9581,7 @@ } ], [ - 463, + 461, 496, 0, { @@ -9631,7 +9601,7 @@ } ], [ - 464, + 462, 497, 0, { @@ -9651,7 +9621,7 @@ } ], [ - 465, + 463, 498, 0, { @@ -9671,7 +9641,7 @@ } ], [ - 466, + 464, 499, 0, { @@ -9691,7 +9661,7 @@ } ], [ - 467, + 465, 500, 0, { @@ -9711,7 +9681,7 @@ } ], [ - 468, + 466, 501, 0, { @@ -9731,7 +9701,7 @@ } ], [ - 469, + 467, 502, 0, { @@ -9751,7 +9721,7 @@ } ], [ - 470, + 468, 503, 0, { @@ -9771,7 +9741,7 @@ } ], [ - 471, + 469, 504, 0, { @@ -9791,7 +9761,7 @@ } ], [ - 472, + 470, 505, 0, { @@ -9811,7 +9781,7 @@ } ], [ - 473, + 471, 506, 0, { @@ -9831,7 +9801,7 @@ } ], [ - 474, + 472, 507, 0, { @@ -9851,7 +9821,7 @@ } ], [ - 475, + 473, 508, 0, { @@ -9871,7 +9841,7 @@ } ], [ - 476, + 474, 509, 0, { @@ -9891,7 +9861,7 @@ } ], [ - 477, + 475, 510, 0, { @@ -9911,7 +9881,7 @@ } ], [ - 478, + 476, 511, 0, { @@ -9931,7 +9901,7 @@ } ], [ - 479, + 477, 512, 0, { @@ -9951,7 +9921,7 @@ } ], [ - 480, + 478, 513, 0, { @@ -9971,7 +9941,7 @@ } ], [ - 481, + 479, 514, 0, { @@ -9991,7 +9961,7 @@ } ], [ - 482, + 480, 515, 0, { @@ -10011,7 +9981,7 @@ } ], [ - 483, + 481, 516, 0, { @@ -10031,7 +10001,7 @@ } ], [ - 484, + 482, 517, 0, { @@ -10051,7 +10021,7 @@ } ], [ - 485, + 483, 518, 0, { @@ -10071,7 +10041,7 @@ } ], [ - 486, + 484, 519, 0, { @@ -10091,7 +10061,7 @@ } ], [ - 487, + 485, 520, 0, { @@ -10111,7 +10081,7 @@ } ], [ - 488, + 486, 521, 0, { @@ -10131,7 +10101,7 @@ } ], [ - 489, + 487, 522, 0, { @@ -10151,7 +10121,7 @@ } ], [ - 490, + 488, 523, 0, { @@ -10171,7 +10141,7 @@ } ], [ - 491, + 489, 524, 0, { @@ -10191,7 +10161,7 @@ } ], [ - 492, + 490, 525, 0, { @@ -10211,7 +10181,7 @@ } ], [ - 493, + 491, 526, 0, { @@ -10231,7 +10201,7 @@ } ], [ - 494, + 492, 527, 0, { @@ -10251,7 +10221,7 @@ } ], [ - 495, + 493, 528, 0, { @@ -10271,7 +10241,7 @@ } ], [ - 496, + 494, 529, 0, { @@ -10291,7 +10261,7 @@ } ], [ - 497, + 495, 530, 0, { @@ -10311,7 +10281,7 @@ } ], [ - 498, + 496, 531, 0, { @@ -10331,7 +10301,7 @@ } ], [ - 499, + 497, 532, 0, { @@ -10351,7 +10321,7 @@ } ], [ - 500, + 498, 533, 0, { @@ -10371,7 +10341,7 @@ } ], [ - 501, + 499, 534, 0, { @@ -10391,7 +10361,7 @@ } ], [ - 502, + 500, 535, 0, { @@ -10411,7 +10381,7 @@ } ], [ - 503, + 501, 536, 0, { @@ -10431,7 +10401,7 @@ } ], [ - 504, + 502, 537, 0, { @@ -10451,7 +10421,7 @@ } ], [ - 505, + 503, 538, 0, { @@ -10471,7 +10441,7 @@ } ], [ - 506, + 504, 539, 0, { @@ -10491,7 +10461,7 @@ } ], [ - 507, + 505, 540, 0, { @@ -10511,7 +10481,7 @@ } ], [ - 508, + 506, 541, 0, { @@ -10531,7 +10501,7 @@ } ], [ - 509, + 507, 542, 0, { @@ -10551,7 +10521,7 @@ } ], [ - 510, + 508, 543, 0, { @@ -10571,7 +10541,7 @@ } ], [ - 511, + 509, 544, 0, { @@ -10591,7 +10561,7 @@ } ], [ - 512, + 510, 545, 0, { @@ -10611,7 +10581,7 @@ } ], [ - 513, + 511, 546, 0, { @@ -10631,7 +10601,7 @@ } ], [ - 514, + 512, 547, 0, { @@ -10651,7 +10621,7 @@ } ], [ - 515, + 513, 548, 0, { @@ -10671,7 +10641,7 @@ } ], [ - 516, + 514, 549, 0, { @@ -10691,7 +10661,7 @@ } ], [ - 517, + 515, 550, 0, { @@ -10711,7 +10681,7 @@ } ], [ - 518, + 516, 551, 0, { @@ -10731,7 +10701,7 @@ } ], [ - 519, + 517, 552, 0, { @@ -10751,7 +10721,7 @@ } ], [ - 520, + 518, 553, 0, { @@ -10771,7 +10741,7 @@ } ], [ - 521, + 519, 554, 0, { @@ -10791,7 +10761,7 @@ } ], [ - 522, + 520, 555, 0, { @@ -10811,7 +10781,7 @@ } ], [ - 523, + 521, 556, 0, { @@ -10831,7 +10801,7 @@ } ], [ - 524, + 522, 557, 0, { @@ -10851,7 +10821,7 @@ } ], [ - 525, + 523, 558, 0, { @@ -10871,7 +10841,7 @@ } ], [ - 526, + 524, 559, 0, { @@ -10891,7 +10861,7 @@ } ], [ - 527, + 525, 560, 0, { @@ -10911,7 +10881,7 @@ } ], [ - 528, + 526, 561, 0, { @@ -10931,7 +10901,7 @@ } ], [ - 529, + 527, 562, 0, { @@ -10951,7 +10921,7 @@ } ], [ - 530, + 528, 563, 0, { @@ -10971,7 +10941,7 @@ } ], [ - 531, + 529, 564, 0, { @@ -10991,7 +10961,7 @@ } ], [ - 532, + 530, 565, 0, { @@ -11011,7 +10981,7 @@ } ], [ - 533, + 531, 566, 0, { @@ -11031,7 +11001,7 @@ } ], [ - 534, + 532, 567, 0, { @@ -11051,7 +11021,7 @@ } ], [ - 535, + 533, 568, 0, { @@ -11071,7 +11041,7 @@ } ], [ - 536, + 534, 569, 0, { @@ -11091,7 +11061,7 @@ } ], [ - 537, + 535, 570, 0, { @@ -11111,7 +11081,7 @@ } ], [ - 538, + 536, 571, 0, { @@ -11131,7 +11101,7 @@ } ], [ - 539, + 537, 572, 0, { @@ -11151,7 +11121,7 @@ } ], [ - 540, + 538, 573, 0, { @@ -11171,7 +11141,7 @@ } ], [ - 541, + 539, 574, 0, { @@ -11191,7 +11161,7 @@ } ], [ - 542, + 540, 577, 0, { @@ -11229,7 +11199,7 @@ } ], [ - 543, + 541, 577, 0, { @@ -11261,10 +11231,10 @@ "----", "", "", - "Let's address the three EmmyLua diagnostics at line 130 in  `lua/opencode/core.lua`:", + "Let's address the three EmmyLua diagnostics at line 130 in `lua/opencode/core.lua`:", "", "### 1. `param-type-not-match`: expected `string` but found `string?`", - "- **Location:**  `state.active_session.id` (line 130, col 20)", + "- **Location:** `state.active_session.id` (line 130, col 20)", "- **Cause:** `state.active_session` may be `nil`, so `state.active_session.id` could error or be `nil`. The function expects a `string`, not a nullable string.", "- **Fix:** Add a nil check for `state.active_session` before accessing `.id`.", "", diff --git a/tests/data/explore.expected.json b/tests/data/explore.expected.json index 1d923b52..82dcbf3f 100644 --- a/tests/data/explore.expected.json +++ b/tests/data/explore.expected.json @@ -1625,51 +1625,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 79, - 81, - 43, - { - "end_col": 74, - "end_right_gravity": false, - "end_row": 81, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 80, - 90, - 7, - { - "end_col": 33, - "end_right_gravity": false, - "end_row": 90, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 81, - 91, - 7, - { - "end_col": 35, - "end_right_gravity": false, - "end_row": 91, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -1754,7 +1709,7 @@ "----", "", "", - "The task tool is rendered primarily in  `lua/opencode/ui/formatter.lua`:", + "The task tool is rendered primarily in `lua/opencode/ui/formatter.lua`:", "", "- **Lines 665–735** — `M._format_tool()`: the central dispatch function that routes tool names to their specific formatters. The `'task'` branch is at **lines 700–707**.", "- **Lines 737–801** — `M._format_task_tool()`: the full task tool renderer, which:", @@ -1763,8 +1718,8 @@ " - Attaches a `select_child_session` contextual action (lines 793–800)", "", "Supporting files:", - "- ` lua/opencode/types.lua:228–308` — type definitions for `TaskToolInput`, `TaskToolMetadata`, and `TaskToolSummaryItem`", - "- ` lua/opencode/ui/icons.lua:13,55` — icon definitions for the task tool", + "- `lua/opencode/types.lua:228–308` — type definitions for `TaskToolInput`, `TaskToolMetadata`, and `TaskToolSummaryItem`", + "- `lua/opencode/ui/icons.lua:13,55` — icon definitions for the task tool", "", "" ], diff --git a/tests/data/markdown-codefence.expected.json b/tests/data/markdown-codefence.expected.json index 98a3439f..7649add7 100644 --- a/tests/data/markdown-codefence.expected.json +++ b/tests/data/markdown-codefence.expected.json @@ -1056,36 +1056,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 42, - 53, - 9, - { - "end_col": 31, - "end_right_gravity": false, - "end_row": 53, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 43, - 60, - 9, - { - "end_col": 34, - "end_right_gravity": false, - "end_row": 60, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -1142,14 +1112,14 @@ " - Keymap: `` remains the same (unless you want to change it)", "", "### 2. Update Codebase", - "- In  `lua/opencode/api.lua`:", + "- In `lua/opencode/api.lua`:", " - Rename the function `M.stop()` to `M.cancel()`", " - Update all references to `stop` (command registration, legacy command map, subcommand routing, etc.) to use `cancel`", " - Ensure legacy command `OpencodeStop` still works (with deprecation warning), but routes to `cancel`", "- In any other files (keymap config, tests, etc.) update references to `stop` to `cancel` as needed", "", "### 3. Update Tests", - "- In  `tests/unit/api_spec.lua`:", + "- In `tests/unit/api_spec.lua`:", " - Update any tests that check for `stop` to check for `cancel`", "", "### 4. Update Slash Commands (if applicable)", diff --git a/tests/data/output-target-navigation.json b/tests/data/output-target-navigation.json index 24427296..40b9a991 100644 --- a/tests/data/output-target-navigation.json +++ b/tests/data/output-target-navigation.json @@ -36,7 +36,7 @@ "messageID": "msg_output_target_navigation", "sessionID": "ses_output_target_navigation", "type": "text", - "text": "Open `lua/opencode/ui/navigation.lua:12:3` from replayed output." + "text": "Open `lua/opencode/ui/navigation.lua:1:3` from replayed output." } } } diff --git a/tests/data/perf.expected.json b/tests/data/perf.expected.json index 5bd86ccd..674ecde1 100644 --- a/tests/data/perf.expected.json +++ b/tests/data/perf.expected.json @@ -194,96 +194,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 9, - 13, - 6, - { - "end_col": 43, - "end_right_gravity": false, - "end_row": 13, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 10, - 16, - 7, - { - "end_col": 40, - "end_right_gravity": false, - "end_row": 16, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 11, - 255, - 8, - { - "end_col": 48, - "end_right_gravity": false, - "end_row": 255, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 12, - 301, - 23, - { - "end_col": 45, - "end_right_gravity": false, - "end_row": 301, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 13, - 301, - 138, - { - "end_col": 162, - "end_right_gravity": false, - "end_row": 301, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 14, - 305, - 34, - { - "end_col": 50, - "end_right_gravity": false, - "end_row": 305, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -300,10 +210,10 @@ "Here's a long markdown-rich session with many fenced code blocks to help test rendering. I include the file you provided plus a variety of smaller snippets in different languages and formats.", "", "**File Contents**", - "-  `lua/opencode/ui/output_window.lua:1`", + "- `lua/opencode/ui/output_window.lua:1`", "", "```lua", - "--  lua/opencode/ui/output_window.lua", + "-- lua/opencode/ui/output_window.lua", "local state = require('opencode.state')", "local config = require('opencode.config')", "", @@ -542,7 +452,7 @@ "**Unified Diff example**", "", "```diff", - "***  before/lua/opencode/ui/output_window.lua", + "*** before/lua/opencode/ui/output_window.lua", "@@", "- vim.api.nvim_set_option_value('winhighlight', config.ui.window_highlight, { win = windows.output_win })", "+ vim.api.nvim_set_option_value('winhighlight', config.ui.window_highlight or '', { win = windows.output_win })", @@ -588,11 +498,11 @@ "", "```bash", "export OPENCODE_ENV=development", - "nvim --headless -u  tests/minimal/init.lua -c \"lua require('plenary.test_harness').test_directory('./tests/unit', {minimal_init = ' ./tests/minimal/init.lua'})\"", + "nvim --headless -u tests/minimal/init.lua -c \"lua require('plenary.test_harness').test_directory('./tests/unit', {minimal_init = './tests/minimal/init.lua'})\"", "```", "", "**Mixed inline code examples**", - "- Use backticks for commands:  `./run_tests.sh`", + "- Use backticks for commands: `./run_tests.sh`", "- File path with start line: `lua/opencode/ui/output_window.lua:1`", "- API call: `vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)`", "", @@ -605,7 +515,6 @@ "", "" ], - "timestamp": 1773947644, "window": { "cursor": [ 316, diff --git a/tests/data/permission-ask-new-approve.expected.json b/tests/data/permission-ask-new-approve.expected.json index dcb86602..7f6fe2e7 100644 --- a/tests/data/permission-ask-new-approve.expected.json +++ b/tests/data/permission-ask-new-approve.expected.json @@ -811,81 +811,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 37, - 44, - 8, - { - "end_col": 40, - "end_right_gravity": false, - "end_row": 44, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 38, - 45, - 8, - { - "end_col": 39, - "end_right_gravity": false, - "end_row": 45, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 39, - 46, - 8, - { - "end_col": 38, - "end_right_gravity": false, - "end_row": 46, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 40, - 49, - 8, - { - "end_col": 18, - "end_right_gravity": false, - "end_row": 49, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 41, - 50, - 8, - { - "end_col": 44, - "end_right_gravity": false, - "end_row": 50, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -933,13 +858,13 @@ "Here are the files that have been changed according to git status:", "", "- Modified (but not staged):", - " -  `lua/opencode/event_manager.lua`", - " -  `lua/opencode/ui/formatter.lua`", - " -  `lua/opencode/ui/renderer.lua`", + " - `lua/opencode/event_manager.lua`", + " - `lua/opencode/ui/formatter.lua`", + " - `lua/opencode/ui/renderer.lua`", "", "- Untracked files:", - " -  `test.lua`", - " -  `tests/data/permission_ask_new.json`", + " - `test.lua`", + " - `tests/data/permission_ask_new.json`", "", "No files are currently staged for commit. Let me know if you want more details (like the diff), want to stage/commit, or need help with anything else!", "", diff --git a/tests/data/redo-all.expected.json b/tests/data/redo-all.expected.json index a9fd8e25..70cbd73e 100644 --- a/tests/data/redo-all.expected.json +++ b/tests/data/redo-all.expected.json @@ -315,21 +315,6 @@ ], [ 9, - 10, - 61, - { - "end_col": 71, - "end_right_gravity": false, - "end_row": 10, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 10, 12, 0, { @@ -349,7 +334,7 @@ } ], [ - 11, + 10, 13, 0, { @@ -369,7 +354,7 @@ } ], [ - 12, + 11, 14, 0, { @@ -389,7 +374,7 @@ } ], [ - 13, + 12, 15, 0, { @@ -421,7 +406,7 @@ } ], [ - 14, + 13, 15, 0, { @@ -441,7 +426,7 @@ } ], [ - 15, + 14, 16, 0, { @@ -473,7 +458,7 @@ } ], [ - 16, + 15, 16, 0, { @@ -493,7 +478,7 @@ } ], [ - 17, + 16, 17, 0, { @@ -523,7 +508,7 @@ } ], [ - 18, + 17, 17, 0, { @@ -543,7 +528,7 @@ } ], [ - 19, + 18, 18, 0, { @@ -573,7 +558,7 @@ } ], [ - 20, + 19, 18, 0, { @@ -593,7 +578,7 @@ } ], [ - 21, + 20, 19, 0, { @@ -613,7 +598,7 @@ } ], [ - 22, + 21, 20, 0, { @@ -633,7 +618,7 @@ } ], [ - 23, + 22, 25, 0, { @@ -671,7 +656,7 @@ } ], [ - 24, + 23, 25, 0, { @@ -690,22 +675,7 @@ } ], [ - 25, - 27, - 40, - { - "end_col": 50, - "end_right_gravity": false, - "end_row": 27, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 26, + 24, 30, 0, { @@ -743,7 +713,7 @@ } ], [ - 27, + 25, 30, 0, { @@ -762,7 +732,7 @@ } ], [ - 28, + 26, 31, 0, { @@ -782,7 +752,7 @@ } ], [ - 29, + 27, 32, 0, { @@ -802,7 +772,7 @@ } ], [ - 30, + 28, 35, 0, { @@ -840,7 +810,7 @@ } ], [ - 31, + 29, 35, 0, { @@ -859,22 +829,7 @@ } ], [ - 32, - 37, - 14, - { - "end_col": 24, - "end_right_gravity": false, - "end_row": 37, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 33, + 30, 42, 0, { @@ -912,7 +867,7 @@ } ], [ - 34, + 31, 42, 0, { @@ -931,7 +886,7 @@ } ], [ - 35, + 32, 46, 0, { @@ -951,7 +906,7 @@ } ], [ - 36, + 33, 47, 0, { @@ -971,7 +926,7 @@ } ], [ - 37, + 34, 48, 0, { @@ -991,7 +946,7 @@ } ], [ - 38, + 35, 49, 0, { @@ -1023,7 +978,7 @@ } ], [ - 39, + 36, 49, 0, { @@ -1043,7 +998,7 @@ } ], [ - 40, + 37, 50, 0, { @@ -1075,7 +1030,7 @@ } ], [ - 41, + 38, 50, 0, { @@ -1095,7 +1050,7 @@ } ], [ - 42, + 39, 51, 0, { @@ -1125,7 +1080,7 @@ } ], [ - 43, + 40, 51, 0, { @@ -1145,7 +1100,7 @@ } ], [ - 44, + 41, 52, 0, { @@ -1175,7 +1130,7 @@ } ], [ - 45, + 42, 52, 0, { @@ -1195,7 +1150,7 @@ } ], [ - 46, + 43, 53, 0, { @@ -1215,7 +1170,7 @@ } ], [ - 47, + 44, 54, 0, { @@ -1235,7 +1190,7 @@ } ], [ - 48, + 45, 59, 0, { @@ -1273,7 +1228,7 @@ } ], [ - 49, + 46, 59, 0, { @@ -1292,22 +1247,7 @@ } ], [ - 50, - 61, - 44, - { - "end_col": 54, - "end_right_gravity": false, - "end_row": 61, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 51, + 47, 64, 0, { @@ -1345,7 +1285,7 @@ } ], [ - 52, + 48, 64, 0, { @@ -1364,7 +1304,7 @@ } ], [ - 53, + 49, 65, 0, { @@ -1384,7 +1324,7 @@ } ], [ - 54, + 50, 66, 0, { @@ -1404,7 +1344,7 @@ } ], [ - 55, + 51, 69, 0, { @@ -1442,7 +1382,7 @@ } ], [ - 56, + 52, 69, 0, { @@ -1461,22 +1401,7 @@ } ], [ - 57, - 71, - 14, - { - "end_col": 24, - "end_right_gravity": false, - "end_row": 71, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 58, + 53, 76, 0, { @@ -1514,7 +1439,7 @@ } ], [ - 59, + 54, 76, 0, { @@ -1533,22 +1458,7 @@ } ], [ - 60, - 78, 55, - { - "end_col": 65, - "end_right_gravity": false, - "end_row": 78, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 61, 80, 0, { @@ -1568,7 +1478,7 @@ } ], [ - 62, + 56, 81, 0, { @@ -1588,7 +1498,7 @@ } ], [ - 63, + 57, 82, 0, { @@ -1608,7 +1518,7 @@ } ], [ - 64, + 58, 83, 0, { @@ -1640,7 +1550,7 @@ } ], [ - 65, + 59, 83, 0, { @@ -1660,7 +1570,7 @@ } ], [ - 66, + 60, 84, 0, { @@ -1692,7 +1602,7 @@ } ], [ - 67, + 61, 84, 0, { @@ -1712,7 +1622,7 @@ } ], [ - 68, + 62, 85, 0, { @@ -1742,7 +1652,7 @@ } ], [ - 69, + 63, 85, 0, { @@ -1762,7 +1672,7 @@ } ], [ - 70, + 64, 86, 0, { @@ -1792,7 +1702,7 @@ } ], [ - 71, + 65, 86, 0, { @@ -1812,7 +1722,7 @@ } ], [ - 72, + 66, 87, 0, { @@ -1832,7 +1742,7 @@ } ], [ - 73, + 67, 88, 0, { @@ -1852,7 +1762,7 @@ } ], [ - 74, + 68, 93, 0, { @@ -1890,7 +1800,7 @@ } ], [ - 75, + 69, 93, 0, { @@ -1907,21 +1817,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 76, - 95, - 44, - { - "end_col": 54, - "end_right_gravity": false, - "end_row": 95, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -1935,7 +1830,7 @@ "----", "", "", - "I'll append a single word (\"again\") to the first line of  `test.txt`. Applying a precise edit to the existing line now.", + "I'll append a single word (\"again\") to the first line of `test.txt`. Applying a precise edit to the existing line now.", "", "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1952,7 +1847,7 @@ "----", "", "", - "**Done:** added the word `again` to  `test.txt`.", + "**Done:** added the word `again` to `test.txt`.", "", "----", "", @@ -1962,7 +1857,7 @@ "----", "", "", - "I'll read  `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", + "I'll read `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", "", "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1986,7 +1881,7 @@ "----", "", "", - "**Done:** appended the word `again2` to  `test.txt`.", + "**Done:** appended the word `again2` to `test.txt`.", "", "----", "", @@ -1996,14 +1891,14 @@ "----", "", "", - "I'll read  `test.txt` to get the current first-line content, then append the word `again3`. Proceeding to read the file.", + "I'll read `test.txt` to get the current first-line content, then append the word `again3`. Proceeding to read the file.", "", "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", "----", "", "", - "I'll append the word \"again3\" to the first line of  `test.txt` with an exact in-place edit. Applying the change now.", + "I'll append the word \"again3\" to the first line of `test.txt` with an exact in-place edit. Applying the change now.", "", "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -2020,7 +1915,7 @@ "----", "", "", - "**Done:** appended the word `again3` to  `test.txt`.", + "**Done:** appended the word `again3` to `test.txt`.", "", "" ], diff --git a/tests/data/redo-once.expected.json b/tests/data/redo-once.expected.json index d81dee60..f63cd5f9 100644 --- a/tests/data/redo-once.expected.json +++ b/tests/data/redo-once.expected.json @@ -276,21 +276,6 @@ ], [ 9, - 10, - 61, - { - "end_col": 71, - "end_right_gravity": false, - "end_row": 10, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 10, 12, 0, { @@ -310,7 +295,7 @@ } ], [ - 11, + 10, 13, 0, { @@ -330,7 +315,7 @@ } ], [ - 12, + 11, 14, 0, { @@ -350,7 +335,7 @@ } ], [ - 13, + 12, 15, 0, { @@ -382,7 +367,7 @@ } ], [ - 14, + 13, 15, 0, { @@ -402,7 +387,7 @@ } ], [ - 15, + 14, 16, 0, { @@ -434,7 +419,7 @@ } ], [ - 16, + 15, 16, 0, { @@ -454,7 +439,7 @@ } ], [ - 17, + 16, 17, 0, { @@ -484,7 +469,7 @@ } ], [ - 18, + 17, 17, 0, { @@ -504,7 +489,7 @@ } ], [ - 19, + 18, 18, 0, { @@ -534,7 +519,7 @@ } ], [ - 20, + 19, 18, 0, { @@ -554,7 +539,7 @@ } ], [ - 21, + 20, 19, 0, { @@ -574,7 +559,7 @@ } ], [ - 22, + 21, 20, 0, { @@ -594,7 +579,7 @@ } ], [ - 23, + 22, 25, 0, { @@ -632,7 +617,7 @@ } ], [ - 24, + 23, 25, 0, { @@ -651,22 +636,7 @@ } ], [ - 25, - 27, - 40, - { - "end_col": 50, - "end_right_gravity": false, - "end_row": 27, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 26, + 24, 30, 0, { @@ -704,7 +674,7 @@ } ], [ - 27, + 25, 30, 0, { @@ -723,7 +693,7 @@ } ], [ - 28, + 26, 31, 0, { @@ -743,7 +713,7 @@ } ], [ - 29, + 27, 32, 0, { @@ -763,7 +733,7 @@ } ], [ - 30, + 28, 35, 0, { @@ -801,7 +771,7 @@ } ], [ - 31, + 29, 35, 0, { @@ -820,22 +790,7 @@ } ], [ - 32, - 37, - 14, - { - "end_col": 24, - "end_right_gravity": false, - "end_row": 37, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 33, + 30, 42, 0, { @@ -873,7 +828,7 @@ } ], [ - 34, + 31, 42, 0, { @@ -892,7 +847,7 @@ } ], [ - 35, + 32, 46, 0, { @@ -912,7 +867,7 @@ } ], [ - 36, + 33, 47, 0, { @@ -932,7 +887,7 @@ } ], [ - 37, + 34, 48, 0, { @@ -952,7 +907,7 @@ } ], [ - 38, + 35, 49, 0, { @@ -984,7 +939,7 @@ } ], [ - 39, + 36, 49, 0, { @@ -1004,7 +959,7 @@ } ], [ - 40, + 37, 50, 0, { @@ -1036,7 +991,7 @@ } ], [ - 41, + 38, 50, 0, { @@ -1056,7 +1011,7 @@ } ], [ - 42, + 39, 51, 0, { @@ -1086,7 +1041,7 @@ } ], [ - 43, + 40, 51, 0, { @@ -1106,7 +1061,7 @@ } ], [ - 44, + 41, 52, 0, { @@ -1136,7 +1091,7 @@ } ], [ - 45, + 42, 52, 0, { @@ -1156,7 +1111,7 @@ } ], [ - 46, + 43, 53, 0, { @@ -1176,7 +1131,7 @@ } ], [ - 47, + 44, 54, 0, { @@ -1196,7 +1151,7 @@ } ], [ - 48, + 45, 59, 0, { @@ -1234,7 +1189,7 @@ } ], [ - 49, + 46, 59, 0, { @@ -1253,22 +1208,7 @@ } ], [ - 50, - 61, - 44, - { - "end_col": 54, - "end_right_gravity": false, - "end_row": 61, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 51, + 47, 69, 0, { @@ -1288,7 +1228,7 @@ } ], [ - 52, + 48, 69, 0, { @@ -1319,7 +1259,7 @@ "----", "", "", - "I'll append a single word (\"again\") to the first line of  `test.txt`. Applying a precise edit to the existing line now.", + "I'll append a single word (\"again\") to the first line of `test.txt`. Applying a precise edit to the existing line now.", "", "** edit** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1336,7 +1276,7 @@ "----", "", "", - "**Done:** added the word `again` to  `test.txt`.", + "**Done:** added the word `again` to `test.txt`.", "", "----", "", @@ -1346,7 +1286,7 @@ "----", "", "", - "I'll read  `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", + "I'll read `test.txt` to get the current first-line content, then append one word (\"again2\") to that line. Proceeding to read the file.", "", "** read** `/home/francis/Projects/_nvim/opencode.nvim/test.txt`", "", @@ -1370,7 +1310,7 @@ "----", "", "", - "**Done:** appended the word `again2` to  `test.txt`.", + "**Done:** appended the word `again2` to `test.txt`.", "", "----", "", diff --git a/tests/data/selection.expected.json b/tests/data/selection.expected.json index 802d5ca9..4f9128f7 100644 --- a/tests/data/selection.expected.json +++ b/tests/data/selection.expected.json @@ -394,21 +394,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 19, - 18, - 23, - { - "end_col": 55, - "end_right_gravity": false, - "end_row": 18, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -430,7 +415,7 @@ "----", "", "", - "I can see the file  `/Users/cam/tmp/a/diff-test.txt` contains \"this is a string\" on line 1.", + "I can see the file `/Users/cam/tmp/a/diff-test.txt` contains \"this is a string\" on line 1.", "", "The two selection contexts you provided show:", "1. Current content: \"this is a string\"", diff --git a/tests/data/shifting-and-multiple-perms.expected.json b/tests/data/shifting-and-multiple-perms.expected.json index 263b7c9f..cee6af7b 100644 --- a/tests/data/shifting-and-multiple-perms.expected.json +++ b/tests/data/shifting-and-multiple-perms.expected.json @@ -197,21 +197,6 @@ ], [ 9, - 24, - 26, - { - "end_col": 40, - "end_right_gravity": false, - "end_row": 24, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 10, 83, 0, { @@ -249,7 +234,7 @@ } ], [ - 11, + 10, 83, 0, { @@ -268,7 +253,7 @@ } ], [ - 12, + 11, 84, 0, { @@ -288,7 +273,7 @@ } ], [ - 13, + 12, 85, 0, { @@ -308,7 +293,7 @@ } ], [ - 14, + 13, 88, 0, { @@ -346,7 +331,7 @@ } ], [ - 15, + 14, 88, 0, { @@ -365,7 +350,7 @@ } ], [ - 16, + 15, 111, 0, { @@ -403,7 +388,7 @@ } ], [ - 17, + 16, 111, 0, { @@ -422,7 +407,7 @@ } ], [ - 18, + 17, 112, 0, { @@ -442,7 +427,7 @@ } ], [ - 19, + 18, 113, 0, { @@ -462,7 +447,7 @@ } ], [ - 20, + 19, 116, 0, { @@ -500,7 +485,7 @@ } ], [ - 21, + 20, 116, 0, { @@ -519,7 +504,7 @@ } ], [ - 22, + 21, 125, 0, { @@ -557,7 +542,7 @@ } ], [ - 23, + 22, 127, 0, { @@ -568,7 +553,7 @@ } ], [ - 24, + 23, 127, 0, { @@ -588,7 +573,7 @@ } ], [ - 25, + 24, 128, 0, { @@ -608,7 +593,7 @@ } ], [ - 26, + 25, 129, 0, { @@ -628,7 +613,7 @@ } ], [ - 27, + 26, 130, 0, { @@ -648,7 +633,7 @@ } ], [ - 28, + 27, 131, 0, { @@ -668,7 +653,7 @@ } ], [ - 29, + 28, 132, 0, { @@ -688,7 +673,7 @@ } ], [ - 30, + 29, 133, 0, { @@ -718,7 +703,7 @@ } ], [ - 31, + 30, 133, 0, { @@ -738,7 +723,7 @@ } ], [ - 32, + 31, 134, 0, { @@ -768,7 +753,7 @@ } ], [ - 33, + 32, 134, 0, { @@ -788,7 +773,7 @@ } ], [ - 34, + 33, 135, 0, { @@ -818,7 +803,7 @@ } ], [ - 35, + 34, 135, 0, { @@ -838,7 +823,7 @@ } ], [ - 36, + 35, 136, 0, { @@ -868,7 +853,7 @@ } ], [ - 37, + 36, 136, 0, { @@ -888,7 +873,7 @@ } ], [ - 38, + 37, 137, 0, { @@ -920,7 +905,7 @@ } ], [ - 39, + 38, 137, 0, { @@ -940,7 +925,7 @@ } ], [ - 40, + 39, 138, 0, { @@ -970,7 +955,7 @@ } ], [ - 41, + 40, 138, 0, { @@ -990,7 +975,7 @@ } ], [ - 42, + 41, 139, 0, { @@ -1020,7 +1005,7 @@ } ], [ - 43, + 42, 139, 0, { @@ -1040,7 +1025,7 @@ } ], [ - 44, + 43, 140, 0, { @@ -1070,7 +1055,7 @@ } ], [ - 45, + 44, 140, 0, { @@ -1090,7 +1075,7 @@ } ], [ - 46, + 45, 141, 0, { @@ -1120,7 +1105,7 @@ } ], [ - 47, + 46, 141, 0, { @@ -1140,7 +1125,7 @@ } ], [ - 48, + 47, 142, 0, { @@ -1160,7 +1145,7 @@ } ], [ - 49, + 48, 143, 0, { @@ -1180,7 +1165,7 @@ } ], [ - 50, + 49, 144, 0, { @@ -1200,7 +1185,7 @@ } ], [ - 51, + 50, 145, 0, { @@ -1211,7 +1196,7 @@ } ], [ - 52, + 51, 145, 0, { @@ -1231,7 +1216,7 @@ } ], [ - 53, + 52, 145, 2, { @@ -1250,7 +1235,7 @@ } ], [ - 54, + 53, 146, 0, { @@ -1270,7 +1255,7 @@ } ], [ - 55, + 54, 147, 0, { @@ -1290,7 +1275,7 @@ } ], [ - 56, + 55, 148, 0, { @@ -1310,7 +1295,7 @@ } ], [ - 57, + 56, 149, 0, { @@ -1330,7 +1315,7 @@ } ], [ - 58, + 57, 150, 0, { @@ -1375,7 +1360,7 @@ "- **Add** a blank line after writing new content (in `_write_formatted_data`)", "- **Remove** the trailing blank line before writing new content (also in `_write_formatted_data`)", "", - "### Changes needed in  `renderer.lua`:", + "### Changes needed in `renderer.lua`:", "", "1. **Add state tracking** (after line 14):", " ```lua", diff --git a/tests/data/updating-text.expected.json b/tests/data/updating-text.expected.json index d7d951d3..ec3f2848 100644 --- a/tests/data/updating-text.expected.json +++ b/tests/data/updating-text.expected.json @@ -194,51 +194,6 @@ "virt_text_pos": "right_align", "virt_text_repeat_linebreak": false } - ], - [ - 9, - 26, - 4, - { - "end_col": 24, - "end_right_gravity": false, - "end_row": 26, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 10, - 38, - 4, - { - "end_col": 26, - "end_right_gravity": false, - "end_row": 38, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } - ], - [ - 11, - 55, - 10, - { - "end_col": 19, - "end_right_gravity": false, - "end_row": 55, - "hl_eol": false, - "hl_group": "OpencodeReference", - "ns_id": 3, - "priority": 1000, - "right_gravity": true - } ] ], "lines": [ @@ -268,7 +223,7 @@ "", "**Minimal example:**", "", - " `plugin/example.lua`:", + "`plugin/example.lua`:", "```lua", "if vim.g.loaded_example then", " return", @@ -280,7 +235,7 @@ "end, {})", "```", "", - " `lua/example/init.lua`:", + "`lua/example/init.lua`:", "```lua", "local M = {}", "", @@ -297,7 +252,7 @@ "```", "", "Key components:", - "- Use  `vim.api` for Neovim API calls", + "- Use `vim.api` for Neovim API calls", "- Provide a `setup()` function for configuration", "- Create user commands with `nvim_create_user_command`", "- Use autocommands with `nvim_create_autocmd`", diff --git a/tests/helpers.lua b/tests/helpers.lua index 7eb181b6..fa18e371 100644 --- a/tests/helpers.lua +++ b/tests/helpers.lua @@ -13,7 +13,7 @@ function M.replay_setup() local renderer = require('opencode.ui.renderer') local permission_window = require('opencode.ui.permission_window') local question_window = require('opencode.ui.question_window') - local reference_picker = require('opencode.ui.reference_picker') + local reference_parser = require('opencode.ui.reference_parser') local empty_promise = require('opencode.promise').new():resolve(nil) config_file.config_promise = empty_promise @@ -33,7 +33,7 @@ function M.replay_setup() question_window._current_question_index = 1 question_window._collected_answers = {} question_window._answering = false - reference_picker.clear_all() + reference_parser.clear_all() ---@diagnostic disable-next-line: duplicate-set-field require('opencode.session').project_id = function() diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index a2926078..a5f1b434 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -201,6 +201,14 @@ describe('renderer unit tests', function() end end) + it('subscribes to file watcher updates for reference target invalidation', function() + assert(vim.tbl_contains(event_subscriptions(), 'file.watcher.updated')) + assert.is_true(require('opencode.ui.event_scope').should_handle('file.watcher.updated', { + file = 'src/ok.lua', + event = 'unlink', + })) + end) + it('unsubsribes from events correctly', function() local renderer = require('opencode.ui.renderer') local event_manager = state.event_manager @@ -325,6 +333,39 @@ describe('renderer unit tests', function() render_stub:revert() end) + it('render_output and render_lines do not write targets into RenderState', function() + local renderer = require('opencode.ui.renderer') + local ctx = require('opencode.ui.renderer.ctx') + local Output = require('opencode.ui.output') + + helpers.replay_setup() + local add_targets_stub = stub(ctx.render_state, 'add_targets') + local clear_targets_stub = stub(ctx.render_state, 'clear_targets') + + local output = Output.new() + output:add_line('open README.md') + output:add_extmark(0, { hl_group = 'OpencodeReference', start_col = 5, end_col = 14 }) + output:add_fold(1, 1) + output:add_target({ + kind = 'file', + path = 'README.md', + range = { line = 1, start_col = 5, end_col = 14 }, + }) + + renderer.render_output(output) + renderer.render_lines({ 'display only' }) + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + + add_targets_stub:revert() + clear_targets_stub:revert() + ui.close_windows(state.windows) + + assert.are.same({ 'display only' }, lines) + assert.stub(add_targets_stub).was_not_called() + assert.stub(clear_targets_stub).was_not_called() + end) + it('inserts a single synthetic revert message during full session render', function() local renderer = require('opencode.ui.renderer') @@ -372,57 +413,90 @@ describe('renderer unit tests', function() state.ui.set_last_code_window(code_win) local path = 'lua/opencode/ui/navigation.lua' + local test_root = vim.fn.tempname() + local absolute_path = test_root .. '/' .. path + vim.fn.mkdir(vim.fn.fnamemodify(absolute_path, ':h'), 'p') + local file = assert(io.open(absolute_path, 'w')) + file:write('abc') + file:close() + + local original_getcwd = vim.fn.getcwd + vim.fn.getcwd = function() + return test_root + end + vim.api.nvim_buf_set_name(code_buf, absolute_path) + vim.api.nvim_buf_set_lines(code_buf, 0, -1, false, { 'abc' }) local events = helpers.load_test_data('tests/data/output-target-navigation.json') state.session.set_active(helpers.get_session_from_events(events, true)) local session_data = helpers.load_session_from_events(events) - renderer._render_full_session_data(session_data) - - local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) - local target_line, target_col - for idx, line in ipairs(lines) do - local col = line:find(path, 1, true) - if col then - target_line = idx - target_col = col - 1 - break + local ok, err = pcall(function() + renderer._render_full_session_data(session_data) + + local lines = vim.api.nvim_buf_get_lines(state.windows.output_buf, 0, -1, false) + local target_line, target_col + for idx, line in ipairs(lines) do + local col = line:find(path, 1, true) + if col then + target_line = idx + target_col = col - 1 + break + end end - end - assert.is_not_nil(target_line, 'replayed output did not contain file reference') - vim.api.nvim_set_current_win(state.windows.output_win) - vim.api.nvim_win_set_cursor(state.windows.output_win, { target_line, target_col }) + assert.is_not_nil(target_line, 'replayed output did not contain file reference') + vim.api.nvim_set_current_win(state.windows.output_win) + vim.api.nvim_win_set_cursor(state.windows.output_win, { target_line, target_col }) - navigation.jump_to_target_at_cursor() + navigation.jump_to_target_at_cursor() - assert.equals(code_win, vim.api.nvim_get_current_win()) - assert.matches(path .. '$', vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(code_win))) - assert.same({ 12, 2 }, vim.api.nvim_win_get_cursor(code_win)) + assert.equals(code_win, vim.api.nvim_get_current_win()) + assert.matches(path .. '$', vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(code_win))) + assert.same({ 1, 2 }, vim.api.nvim_win_get_cursor(code_win)) + end) + vim.fn.getcwd = original_getcwd pcall(vim.api.nvim_win_close, code_win, true) pcall(vim.api.nvim_buf_delete, code_buf, { force = true }) + pcall(vim.fn.delete, test_root, 'rf') + if not ok then + error(err) + end end) it('renders reference-scoped symbol highlights through full session replay', function() local renderer = require('opencode.ui.renderer') - local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local symbol_snapshot = require('opencode.ui.symbol_snapshot') local events = helpers.load_test_data('tests/data/symbol-reference-navigation.json') local referenced_file = 'lua/opencode/ui/symbol_snapshot.lua' - - package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function(refs) - assert.are.equal(1, #refs) - assert.are.equal(referenced_file, refs[1].file_path) - return { by_token = { collect = true } } - end, - token_variants = function(token) - return { token } - end, - has_token = function(_, token) - return token == 'collect' - end, - } + local cycle = { id = 'cycle' } + local new_cycle_stub = stub(symbol_snapshot, 'new_cycle').returns(cycle) + local targets_for_token_stub = stub(symbol_snapshot, 'targets_for_token').invokes( + function(received_cycle, token, candidate_files) + assert.are.equal(cycle, received_cycle) + if token ~= 'collect' then + return {} + end + assert.are.equal(1, #candidate_files) + assert.matches(referenced_file .. '$', candidate_files[1]) + return { + { + path = candidate_files[1], + line = 1, + col = 10, + token = token, + }, + } + end + ) helpers.replay_setup() + local original_filereadable = vim.fn.filereadable + vim.fn.filereadable = function(path) + if path:match(referenced_file .. '$') then + return 1 + end + return original_filereadable(path) + end state.session.set_active(helpers.get_session_from_events(events, true)) renderer._render_full_session_data(helpers.load_session_from_events(events)) @@ -435,7 +509,9 @@ describe('renderer unit tests', function() end end - package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + new_cycle_stub:revert() + targets_for_token_stub:revert() + vim.fn.filereadable = original_filereadable assert.is_not_nil(symbol_mark) end) diff --git a/tests/unit/formatter_spec.lua b/tests/unit/formatter_spec.lua index 45d6d557..ca4b0d58 100644 --- a/tests/unit/formatter_spec.lua +++ b/tests/unit/formatter_spec.lua @@ -122,12 +122,15 @@ describe('formatter', function() }, } - local output = formatter.format_part(part, message, true, function(session_id) - if session_id == 'ses_child' then - return child_parts - end - return nil - end) + local output = formatter.format_part(part, message, true, { + interactive = true, + get_child_parts = function(session_id) + if session_id == 'ses_child' then + return child_parts + end + return nil + end, + }) assert.are.equal(' ** tool** ', output.lines[3]) end) @@ -184,12 +187,15 @@ describe('formatter', function() }, } - local output = formatter.format_part(part, message, true, function(session_id) - if session_id == 'ses_child' then - return child_parts - end - return nil - end) + local output = formatter.format_part(part, message, true, { + interactive = true, + get_child_parts = function(session_id) + if session_id == 'ses_child' then + return child_parts + end + return nil + end, + }) local found = false for _, line in ipairs(output.lines) do @@ -269,7 +275,7 @@ describe('formatter', function() assert.are.equal('** read** `/tmp/project/` 1s', output.lines[1]) end) - it('renders diff line numbers as extmarks', function() + it('renders diff line numbers as extmarks and targets', function() local output = Output.new() local formatter_utils = require('opencode.ui.formatter.utils') @@ -285,7 +291,8 @@ describe('formatter', function() ' gamma', '+beta', }, '\n'), - 'lua' + 'lua', + '/test/project/lua/foo.lua' ) assert.are.equal(' alpha', output.lines[3]) @@ -305,82 +312,173 @@ describe('formatter', function() assert.are.equal('11', add_mark.virt_text[1][1]) assert.are.equal('+', add_mark.virt_text[2][1]) assert.are.equal('OpencodeDiffAddGutter', add_mark.virt_text[1][2]) + + assert.are.same({ + { + kind = 'diff', + path = '/test/project/lua/foo.lua', + line = 10, + range = { line = 4, start_col = 0, end_col = 9 }, + }, + { + kind = 'diff', + path = '/test/project/lua/foo.lua', + line = 11, + range = { line = 5, start_col = 0, end_col = 8 }, + }, + }, output.targets) end) - it('highlights assistant symbols from the reference-scoped snapshot without current text refs', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] - local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + it('projects supplied reference facts instead of deriving them during assistant render', function() + local reference_parser = require('opencode.ui.reference_parser') + local original_parse_references = reference_parser.parse_references + reference_parser.parse_references = function() + error('assistant render must consume reference facts, not parse assistant text') + end - package.loaded['opencode.ui.reference_picker'] = { - parse_references = function() - return {} + local original_messages = state.messages + state.renderer.set_messages(setmetatable({}, { + __pairs = function() + error('assistant render must not scan state.messages') end, - collect_refs = function() - return { { file_path = 'src/main.lua' } } + __ipairs = function() + error('assistant render must not scan state.messages') end, + })) + + local text = 'See `src/foo.lua` now' + local part = { + id = 'part_render_boundary', + type = 'text', + text = text, + messageID = 'msg_render_boundary', + sessionID = 'ses_1', } - package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function(refs) - assert.are.same({ { file_path = 'src/main.lua' } }, refs) - return { by_token = { foo = true } } - end, - token_variants = function(token) - return { token } - end, - has_token = function(_, token) - return token == 'foo' - end, + local message = { + info = { id = 'msg_render_boundary', role = 'assistant', sessionID = 'ses_1' }, + parts = { part }, } - local output = Output.new() - formatter._format_assistant_message(output, 'foo bar', 'msg_symbols') + local ok, err = pcall(function() + local output = formatter.format_part(part, message, true, { + interactive = true, + current_files = { vim.fn.getcwd() .. '/src/foo.lua' }, + current_refs = {}, + }) - package.loaded['opencode.ui.reference_picker'] = original_reference_picker - package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + assert.are.equal(text, output.lines[1]) + assert.are.same({}, output.targets) + end) - assert.are.equal('foo bar', output.lines[1]) - assert.are.equal('OpencodeSymbolReference', output.extmarks[0][1].hl_group) - assert.are.equal(0, output.extmarks[0][1].start_col) - assert.are.equal(3, output.extmarks[0][1].end_col) - assert.is_nil(output.extmarks[0][1].target) + reference_parser.parse_references = original_parse_references + state.renderer.set_messages(original_messages) + + assert.is_true(ok, err) end) - it('does not let symbol highlights overwrite rendered file reference spans', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + it('maps supplied reference facts to executable rendered file targets after trim', function() + local reference_facts = require('opencode.ui.reference_facts') + local icons = require('opencode.ui.icons') + local raw_text = ' See `src/foo.lua:12:3` now ' + local part = { + id = 'part_trimmed_ref', + type = 'text', + text = raw_text, + messageID = 'msg_trimmed_ref', + sessionID = 'ses_1', + } + local message = { + info = { id = 'msg_trimmed_ref', role = 'assistant', sessionID = 'ses_1' }, + parts = { part }, + } + + reference_facts.clear() + reference_facts.rebuild('ses_1', { message }) + + local refs = reference_facts.current_refs() + local output = formatter.format_part(part, message, true, { + interactive = true, + current_refs = refs, + current_files = { vim.fn.getcwd() .. '/src/foo.lua' }, + }) + local rendered_ref_start = output.lines[1]:find('`src/foo.lua:12:3`', 1, true) - 1 + local raw_ref_start, raw_ref_end = raw_text:find('`src/foo.lua:12:3`', 1, true) + + reference_facts.clear() + + assert.are.same({ start_offset = raw_ref_start, end_offset = raw_ref_end }, refs[1].raw_range) + assert.are.equal('See ' .. icons.get('reference') .. '`src/foo.lua:12:3` now', output.lines[1]) + assert.are.same({ + kind = 'file', + path = vim.fn.getcwd() .. '/src/foo.lua', + line = 12, + col = 3, + range = { + line = 1, + start_col = rendered_ref_start, + end_col = rendered_ref_start + #'`src/foo.lua:12:3`', + }, + }, output.targets[1]) + end) + + it('leaves unavailable file mentions inert', function() + local text = 'See `src/missing.lua` now' + local ref_start, ref_end = text:find('`src/missing.lua`', 1, true) + local part = { id = 'part_missing_ref', text = text } + local message = { info = { id = 'msg_missing_ref' }, parts = { part } } + + local output = Output.new() + formatter._format_assistant_message(output, text, part, message, { + interactive = true, + current_files = {}, + current_refs = { + { + message_id = 'msg_missing_ref', + part_id = 'part_missing_ref', + path = 'src/missing.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = ref_start, end_offset = ref_end }, + }, + }, + }) + + assert.are.equal(text, output.lines[1]) + assert.are.same({}, output.targets) + assert.is_nil(output.extmarks[0]) + end) + + it('creates symbol targets from same-part file references before the token', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] local text = 'See `src/foo.lua` foo' local ref_start, ref_end = text:find('`src/foo.lua`', 1, true) - - package.loaded['opencode.ui.reference_picker'] = { - parse_references = function() - return { - { - file_path = 'src/foo.lua', - match_start = ref_start, - match_end = ref_end, - }, - } - end, - collect_refs = function() - return { { file_path = 'src/foo.lua' } } - end, - } + local part = { id = 'part_file_ref', text = text } + local message = { info = { id = 'msg_file_ref' }, parts = { part } } package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - return { by_token = {} } - end, - token_variants = function(token) - return { token } - end, - has_token = function(_, token) - return token == 'src/foo.lua' or token == 'foo' + targets_for_token = function(_, token, candidate_files) + assert.are.same({ vim.fn.getcwd() .. '/src/foo.lua' }, candidate_files) + if token == 'foo' then + return { { token = 'foo', path = vim.fn.getcwd() .. '/src/foo.lua', line = 1, col = 1 } } + end + return {} end, } local output = Output.new() - formatter._format_assistant_message(output, text, 'msg_file_ref') + formatter._format_assistant_message(output, text, part, message, { + interactive = true, + current_files = { vim.fn.getcwd() .. '/src/foo.lua' }, + current_refs = { + { + message_id = 'msg_file_ref', + part_id = 'part_file_ref', + path = vim.fn.getcwd() .. '/src/foo.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = ref_start, end_offset = ref_end }, + }, + }, + symbol_cycle = {}, + }) - package.loaded['opencode.ui.reference_picker'] = original_reference_picker package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot local symbol_mark @@ -398,83 +496,146 @@ describe('formatter', function() assert.is_not_nil(symbol_mark) assert.are.equal(trailing_foo_start - 1, symbol_mark.start_col) assert.are.equal(trailing_foo_start + 2, symbol_mark.end_col) + assert.are.same({ + { + kind = 'file', + path = vim.fn.getcwd() .. '/src/foo.lua', + range = output.targets[1].range, + }, + { + kind = 'symbol', + token = 'foo', + candidate_files = { vim.fn.getcwd() .. '/src/foo.lua' }, + range = { line = 1, start_col = trailing_foo_start - 1, end_col = trailing_foo_start + 2 }, + }, + }, { + { + kind = output.targets[1].kind, + path = output.targets[1].path, + range = output.targets[1].range, + }, + output.targets[2], + }) end) - it('does not highlight symbol-looking segments inside paths', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + it('does not create symbol targets without local candidate files', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] - package.loaded['opencode.ui.reference_picker'] = { - parse_references = function() - return {} - end, - collect_refs = function() - return { { file_path = 'lua/opencode/ui/symbol_snapshot.lua' } } - end, - } package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - return { by_token = {} } - end, - token_variants = function(token) - return { token } - end, - has_token = function(_, token) - return token == 'data' or token == 'navigation' or token == 'cache' + targets_for_token = function() + error('symbol lookup requires local candidate files') end, } local output = Output.new() - formatter._format_assistant_message( - output, - 'See tests/data/symbol-reference-navigation.json and .cache/', - 'msg_path' - ) + formatter._format_assistant_message(output, 'foo bar', { id = 'part_no_candidates' }, nil, { + interactive = true, + current_files = { '/test/project/src/foo.lua' }, + current_refs = {}, + symbol_cycle = {}, + }) - package.loaded['opencode.ui.reference_picker'] = original_reference_picker package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + assert.are.equal('foo bar', output.lines[1]) + assert.are.same({}, output.targets) assert.is_nil(output.extmarks[0]) end) - it('uses part-level reference parse keys for assistant text parts', function() + it('uses same-message previous file refs as symbol candidates', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] - local reference_picker = require('opencode.ui.reference_picker') - reference_picker.clear_all() package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - return { by_token = {} } - end, - token_variants = function(token) - return { token } - end, - has_token = function() - return false + targets_for_token = function(_, token, candidate_files) + assert.are.same({ vim.fn.getcwd() .. '/src/main.lua' }, candidate_files) + return token == 'foo' and { { token = 'foo', path = vim.fn.getcwd() .. '/src/main.lua', line = 1, col = 1 } } + or {} end, } + local previous_part = { id = 'tool_1', type = 'tool' } + local current_part = { id = 'text_1', type = 'text', text = 'foo' } + local message = { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { previous_part, current_part }, + } + + local output = Output.new() + formatter._format_assistant_message(output, 'foo', current_part, message, { + interactive = true, + current_files = { vim.fn.getcwd() .. '/src/main.lua' }, + current_refs = { + { + message_id = 'msg_1', + part_id = 'tool_1', + path = 'src/main.lua', + source_kind = 'tool_file_path', + }, + }, + symbol_cycle = {}, + }) + + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + + assert.are.same({ + { + kind = 'symbol', + token = 'foo', + candidate_files = { vim.fn.getcwd() .. '/src/main.lua' }, + range = { line = 1, start_col = 0, end_col = 3 }, + }, + }, output.targets) + assert.are.equal('OpencodeSymbolReference', output.extmarks[0][1].hl_group) + end) + + it('does not highlight symbol-looking segments inside paths', function() + local output = Output.new() + formatter._format_assistant_message(output, 'See tests/data/symbol-reference-navigation.json and .cache/') + + assert.is_nil(output.extmarks[0]) + end) + + it('uses part identity to select assistant text reference facts', function() local message = { info = { id = 'msg_same', role = 'assistant', sessionID = 'ses_1' }, parts = {}, } - local first = formatter.format_part({ + local part_a = { id = 'part_a', type = 'text', text = 'See `a.lua`', messageID = 'msg_same', sessionID = 'ses_1', - }, message, false) - local second = formatter.format_part({ + } + local part_b = { id = 'part_b', type = 'text', text = 'See `b.lua`', messageID = 'msg_same', sessionID = 'ses_1', - }, message, true) - - package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot - reference_picker.clear_all() + } + local a_start, a_end = part_a.text:find('`a.lua`', 1, true) + local b_start, b_end = part_b.text:find('`b.lua`', 1, true) + local context = { + current_refs = { + { + message_id = 'msg_same', + part_id = 'part_a', + path = 'a.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = a_start, end_offset = a_end }, + }, + { + message_id = 'msg_same', + part_id = 'part_b', + path = 'b.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = b_start, end_offset = b_end }, + }, + }, + } + local first = formatter.format_part(part_a, message, false, context) + local second = formatter.format_part(part_b, message, true, context) assert.is_truthy(first.lines[1]:find('a.lua', 1, true)) assert.is_nil(first.lines[1]:find('b.lua', 1, true)) @@ -483,39 +644,43 @@ describe('formatter', function() end) it('highlights a symbol before trailing prose colon', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] - - package.loaded['opencode.ui.reference_picker'] = { - parse_references = function() - return {} - end, - collect_refs = function() - return { { file_path = 'src/main.lua' } } - end, - } + local text = 'See `src/main.lua` foo: call this' + local ref_start, ref_end = text:find('`src/main.lua`', 1, true) + local part = { id = 'part_colon', text = text } + local message = { info = { id = 'msg_colon' }, parts = { part } } package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - return { by_token = {} } - end, - token_variants = function(token) - return { token } - end, - has_token = function(_, token) - return token == 'foo' + targets_for_token = function(_, token, candidate_files) + assert.are.same({ vim.fn.getcwd() .. '/src/main.lua' }, candidate_files) + return token == 'foo' and { { token = 'foo', path = vim.fn.getcwd() .. '/src/main.lua', line = 3, col = 1 } } + or {} end, } local output = Output.new() - formatter._format_assistant_message(output, 'foo: call this', 'msg_colon') + formatter._format_assistant_message(output, text, part, message, { + interactive = true, + current_files = { vim.fn.getcwd() .. '/src/main.lua' }, + current_refs = { + { + message_id = 'msg_colon', + part_id = 'part_colon', + path = 'src/main.lua', + source_kind = 'assistant_text', + raw_range = { start_offset = ref_start, end_offset = ref_end }, + }, + }, + symbol_cycle = {}, + }) - package.loaded['opencode.ui.reference_picker'] = original_reference_picker package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot - assert.are.equal('foo: call this', output.lines[1]) - assert.are.equal(1, #output.extmarks[0]) - assert.are.equal(0, output.extmarks[0][1].start_col) - assert.are.equal(3, output.extmarks[0][1].end_col) + local symbol_mark = output.extmarks[0][2] + local foo_start = output.lines[1]:find('foo:', 1, true) + assert.are.equal(text:gsub('See ', 'See ' .. require('opencode.ui.icons').get('reference'), 1), output.lines[1]) + assert.are.equal(foo_start - 1, symbol_mark.start_col) + assert.are.equal(foo_start + 2, symbol_mark.end_col) + assert.are.equal('foo', output.targets[2].token) end) it('formats grep tools when streamed input contains vim.NIL placeholders', function() @@ -822,12 +987,15 @@ describe('formatter', function() }, } - local output = formatter.format_part(part, message, true, function(session_id) - if session_id == 'ses_child' then - return child_parts - end - return nil - end) + local output = formatter.format_part(part, message, true, { + interactive = true, + get_child_parts = function(session_id) + if session_id == 'ses_child' then + return child_parts + end + return nil + end, + }) assert.are.same({ text = '[S] Open this Session', @@ -837,6 +1005,7 @@ describe('formatter', function() display_line = 1, range = { from = 2, to = 5 }, }, output.actions[1]) + assert.is_truthy(table.concat(output.lines, '\n'):find('read', 1, true)) end) describe('fold_exclude', function() diff --git a/tests/unit/hooks_spec.lua b/tests/unit/hooks_spec.lua index 2df42ffe..88db2edf 100644 --- a/tests/unit/hooks_spec.lua +++ b/tests/unit/hooks_spec.lua @@ -1,4 +1,5 @@ local renderer = require('opencode.ui.renderer') +local stub = require('luassert.stub') local config = require('opencode.config') local state = require('opencode.state') local session_runtime = require('opencode.services.session_runtime') @@ -6,7 +7,6 @@ local events = require('opencode.ui.renderer.events') local helpers = require('tests.helpers') local ui = require('opencode.ui.ui') - local function expect_nil_hook_no_error(run) assert.has_no.errors(run) end @@ -232,3 +232,52 @@ describe('hooks', function() end) end) end) + +describe('reference target local file lifecycle autocmds', function() + local autocmds = require('opencode.ui.autocmds') + + it('invalidates rendered reference targets on local file writes, renames, unloads, and shell changes', function() + local original_create_augroup = vim.api.nvim_create_augroup + local original_create_autocmd = vim.api.nvim_create_autocmd + local created = {} + + local invalidate_stub = stub(events, 'invalidate_reference_targets_for_file_change') + local ok, err = pcall(function() + vim.api.nvim_create_augroup = function() + return 42 + end + vim.api.nvim_create_autocmd = function(event, opts) + created[#created + 1] = { event = event, opts = opts } + return #created + end + + autocmds.setup_autocmds({ input_win = 1, output_win = 2, footer_win = 3, input_buf = 4, output_buf = 5 }) + + local file_lifecycle_autocmd + for _, entry in ipairs(created) do + if type(entry.event) == 'table' and vim.tbl_contains(entry.event, 'BufWritePost') then + file_lifecycle_autocmd = entry + break + end + end + + assert.is_not_nil(file_lifecycle_autocmd) + assert.are.same( + { 'BufWritePost', 'BufFilePost', 'BufDelete', 'BufWipeout', 'FileChangedShellPost' }, + file_lifecycle_autocmd.event + ) + + file_lifecycle_autocmd.opts.callback({ file = '/repo/tests/unit/formatter_spec.lua' }) + file_lifecycle_autocmd.opts.callback({ file = '' }) + + assert.stub(invalidate_stub).was_called(1) + end) + + vim.api.nvim_create_augroup = original_create_augroup + vim.api.nvim_create_autocmd = original_create_autocmd + invalidate_stub:revert() + if not ok then + error(err) + end + end) +end) diff --git a/tests/unit/navigation_spec.lua b/tests/unit/navigation_spec.lua index e5e3033a..ad295eed 100644 --- a/tests/unit/navigation_spec.lua +++ b/tests/unit/navigation_spec.lua @@ -4,28 +4,11 @@ local stub = require('luassert.stub') local navigation = require('opencode.ui.navigation') local config = require('opencode.config') local ui = require('opencode.ui.ui') -local output_window = require('opencode.ui.output_window') local renderer = require('opencode.ui.renderer') local state = require('opencode.state') local existing_path = 'lua/opencode/ui/navigation.lua' -local function set_cursor_on(win, line_num, line, needle) - local start_pos = assert(line:find(needle, 1, true)) - vim.api.nvim_win_set_cursor(win, { line_num, start_pos - 1 }) -end - -local function add_diff_extmark(buf, line_idx, gutter, sign) - vim.api.nvim_buf_set_extmark(buf, output_window.namespace, line_idx, 0, { - virt_text = { - { gutter, 'LineNr' }, - { sign, 'DiffAdd' }, - { ' ', 'Normal' }, - }, - virt_text_pos = 'inline', - }) -end - describe('output token navigation', function() local output_buf, output_win, input_buf, input_win, code_buf, code_win local original_windows, original_code_win, original_code_buf, original_config @@ -79,151 +62,275 @@ describe('output token navigation', function() config.values = original_config end) - it('resolves an ordinary visible path token at the cursor', function() - local line = 'open ' .. existing_path - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) - set_cursor_on(output_win, 1, line, existing_path) + it('resolves the rendered target under the cursor', function() + local target = { kind = 'file', path = existing_path, line = 12, col = 3 } + local target_stub = stub(renderer, 'get_target_at_position').returns(target) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'open target' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 4 }) - local target = navigation.resolve_target_at_cursor() + assert.same(target, navigation.resolve_target_at_cursor()) + assert.stub(target_stub).was_called_with(1, 4) - assert.same({ path = existing_path }, target) + target_stub:revert() end) - it('resolves path line and column from the cursor token', function() - local token = existing_path .. ':12:3' - local line = 'open ' .. token - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) - set_cursor_on(output_win, 1, line, token) - - local target = navigation.resolve_target_at_cursor() - - assert.same({ path = existing_path, line = 12, col = 3 }, target) - end) - - it('resolves backtick, file uri, markdown, and tool-path forms', function() - local cases = { - { '`' .. existing_path .. ':4`', existing_path, 4 }, - { 'file://' .. existing_path .. ':5', existing_path, 5 }, - { '[`' .. existing_path .. '`](file)', existing_path, nil }, - { '**tool** `' .. existing_path .. '`', existing_path, nil }, + it('executes file and diff rendered targets on ', function() + local original_navigate_to_location = navigation.navigate_to_location + local navigated = {} + local targets = { + { kind = 'file', path = existing_path, line = 7, col = 2 }, + { kind = 'diff', path = existing_path, line = 42 }, } + local index = 0 + local target_stub = stub(renderer, 'get_target_at_position').invokes(function() + index = index + 1 + return targets[index] + end) + navigation.navigate_to_location = function(path, line, col) + navigated[#navigated + 1] = { path = path, line = line, col = col } + return true + end + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'file', 'diff' }) - for _, case in ipairs(cases) do - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { case[1] }) - set_cursor_on(output_win, 1, case[1], existing_path) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + navigation.jump_to_target_at_cursor() - local target = navigation.resolve_target_at_cursor() + navigation.navigate_to_location = original_navigate_to_location + target_stub:revert() - assert.same({ path = case[2], line = case[3] }, target) - end + assert.same({ + { path = existing_path, line = 7, col = 2 }, + { path = existing_path, line = 42, col = nil }, + }, navigated) end) - it('uses only the path token under the cursor on multi-token lines', function() - local first = 'lua/opencode/api.lua' - local second = existing_path .. ':7' - local line = first .. ' then ' .. second - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) + it('leaves target lifecycle to render invalidation when a rendered file target fails', function() + local original_navigate_to_location = navigation.navigate_to_location + local dirty_stub = stub(renderer, 'mark_part_dirty') + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'file', + path = 'missing.lua', + part_id = 'part_1', + message_id = 'msg_1', + }) + navigation.navigate_to_location = function() + return false + end - set_cursor_on(output_win, 1, line, second) - assert.same({ path = existing_path, line = 7 }, navigation.resolve_target_at_cursor()) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'missing.lua' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + target_stub:revert() - vim.api.nvim_win_set_cursor(output_win, { 1, #first + 2 }) - assert.is_nil(navigation.resolve_target_at_cursor()) + assert.stub(dirty_stub).was_not_called() + dirty_stub:revert() end) - it('uses diff extmark new-file line for add and context rows', function() - local header = '[`' .. existing_path .. '`](file)' - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { header, '+ added', ' context' }) + it('does not dirty the source part after a rendered diff target opens', function() + local original_navigate_to_location = navigation.navigate_to_location + local dirty_stub = stub(renderer, 'mark_part_dirty') + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'diff', + path = existing_path, + part_id = 'part_1', + message_id = 'msg_1', + }) + navigation.navigate_to_location = function() + return true + end - add_diff_extmark(output_buf, 1, ' 42 ', '+') - vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) - assert.same({ path = existing_path, line = 42 }, navigation.resolve_target_at_cursor()) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { existing_path }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + + navigation.navigate_to_location = original_navigate_to_location + target_stub:revert() - add_diff_extmark(output_buf, 2, ' 43 ', ' ') - vim.api.nvim_win_set_cursor(output_win, { 3, 0 }) - assert.same({ path = existing_path, line = 43 }, navigation.resolve_target_at_cursor()) + assert.stub(dirty_stub).was_not_called() + dirty_stub:revert() end) - it('does not jump deleted diff rows to old-file lines', function() - local header = '[`' .. existing_path .. '`](file)' - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { header, '- deleted' }) - add_diff_extmark(output_buf, 1, ' 9 ', '-') - vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + it('keypress consumes rendered targets instead of deriving targets from screen text', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated = {} + local target_stub = stub(renderer, 'get_target_at_position').returns(nil) - assert.is_nil(navigation.resolve_target_at_cursor()) - end) + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + error('symbol target resolution must not run without a rendered target') + end, + targets_for_token = function() + error('symbol target resolution must not run without a rendered target') + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated[#navigated + 1] = { path = path, line = line, col = col } + return true + end + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { '`' .. existing_path .. '`', 'foo' }) + local ok, err = pcall(function() + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + vim.api.nvim_win_set_cursor(output_win, { 2, 1 }) + navigation.jump_to_file_at_cursor() + end) - it('keeps gf file-only and silent on missing path or plain text', function() - local notify_stub = stub(vim, 'notify') - local load_stub = stub(renderer, 'load_all_messages') - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { '`missing/not_here.lua`', 'plain text' }) - set_cursor_on(output_win, 1, '`missing/not_here.lua`', 'missing/not_here.lua') - local before_win = vim.api.nvim_get_current_win() - local before_cursor = vim.api.nvim_win_get_cursor(output_win) + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + target_stub:revert() + + assert.is_true(ok, err) + assert.are.same({}, navigated) + assert.stub(target_stub).was_called(2) + assert.are.same(1, target_stub.calls[1].refs[1]) + assert.are.same(0, target_stub.calls[1].refs[2]) + assert.are.same(2, target_stub.calls[2].refs[1]) + assert.are.same(1, target_stub.calls[2].refs[2]) + assert.are.same('function', type(target_stub.calls[2].refs[3])) + end) - navigation.jump_to_file_at_cursor() + it('keeps gf file-and-diff only', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_navigate_to_location = navigation.navigate_to_location + local navigated = {} + local targets = { + { kind = 'diff', path = existing_path, line = 9 }, + { + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + }, + } + local index = 0 + local target_stub = stub(renderer, 'get_target_at_position').invokes(function(_, _, filter) + index = index + 1 + local target = targets[index] + if filter and not filter(target) then + return nil + end + return target + end) + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + error('gf must not resolve symbol targets') + end, + targets_for_token = function() + error('gf must not resolve symbol targets') + end, + } + navigation.navigate_to_location = function(path, line, col) + navigated[#navigated + 1] = { path = path, line = line, col = col } + return true + end - assert.equals(before_win, vim.api.nvim_get_current_win()) - assert.same(before_cursor, vim.api.nvim_win_get_cursor(output_win)) - assert.stub(notify_stub).was_not_called() - assert.stub(load_stub).was_not_called() + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'diff', 'foo' }) + local ok, err = pcall(function() + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_file_at_cursor() + vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) + navigation.jump_to_file_at_cursor() + end) - vim.api.nvim_win_set_cursor(output_win, { 2, 0 }) - navigation.jump_to_file_at_cursor() - assert.equals(before_win, vim.api.nvim_get_current_win()) - assert.stub(notify_stub).was_not_called() - assert.stub(load_stub).was_not_called() + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + target_stub:revert() - notify_stub:revert() - load_stub:revert() + assert.is_true(ok, err) + assert.same({ { path = existing_path, line = 9, col = nil } }, navigated) end) - it('uses symbol fallback only after file resolution misses', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + it('executes a symbol rendered target with current file contents', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] local original_navigate_to_location = navigation.navigate_to_location local navigated - - package.loaded['opencode.ui.reference_picker'] = { - collect_refs = function() - return { { file_path = 'src/main.lua' } } - end, - } + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + }) package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function(refs) - assert.same({ { file_path = 'src/main.lua' } }, refs) - return { by_token = {} } + new_cycle = function() + return { cycle = 'fresh' } end, - token_variants = function(token) - assert.equal('M.actions.jump_to_file', token) - return { 'M.actions.jump_to_file', 'actions.jump_to_file', 'jump_to_file' } - end, - targets_for_token = function(_, token) - if token == 'jump_to_file' then - return { { token = 'jump_to_file', path = existing_path, line = 12, col = 3 } } - end - return {} + targets_for_token = function(cycle, token, candidate_files) + assert.same({ cycle = 'fresh' }, cycle) + assert.equal('foo', token) + assert.same({ existing_path }, candidate_files) + return { { token = 'foo', path = existing_path, line = 3, col = 1 } } end, } navigation.navigate_to_location = function(path, line, col) navigated = { path = path, line = line, col = col } + return true end + state.renderer.set_messages(setmetatable({}, { + __pairs = function() + error('symbol target navigation must not scan state.messages') + end, + __ipairs = function() + error('symbol target navigation must not scan state.messages') + end, + })) - local line = 'call M.actions.jump_to_file now' - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) - set_cursor_on(output_win, 1, line, 'M.actions.jump_to_file') + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) + local ok, err = pcall(function() + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) + navigation.jump_to_target_at_cursor() + end) + navigation.navigate_to_location = original_navigate_to_location + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + state.renderer.set_messages({}) + target_stub:revert() + + assert.is_true(ok, err) + assert.same({ path = existing_path, line = 3, col = 1 }, navigated) + end) + + it('reports symbol misses without mutating target lifecycle', function() + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local notify_stub = stub(vim, 'notify') + local dirty_stub = stub(renderer, 'mark_part_dirty') + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + }) + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + return {} + end, + targets_for_token = function() + return {} + end, + } + + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) navigation.jump_to_target_at_cursor() - navigation.navigate_to_location = original_navigate_to_location - package.loaded['opencode.ui.reference_picker'] = original_reference_picker package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + target_stub:revert() - assert.same({ path = existing_path, line = 12, col = 3 }, navigated) + assert.stub(notify_stub).was_called_with('No symbol target found: foo', vim.log.levels.INFO) + assert.stub(dirty_stub).was_not_called() + + notify_stub:revert() + dirty_stub:revert() end) - it('offers multiple symbol fallback targets through the base picker', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + it('offers multiple symbol rendered targets through the base picker', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] local original_base_picker = package.loaded['opencode.ui.base_picker'] local original_navigate_to_location = navigation.navigate_to_location @@ -233,18 +340,17 @@ describe('output token navigation', function() { token = 'foo', path = existing_path, line = 1, col = 1, kind = 'function' }, { token = 'foo', path = existing_path, line = 2, col = 1 }, } + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', + }) - package.loaded['opencode.ui.reference_picker'] = { - collect_refs = function() - return { { file_path = existing_path } } - end, - } package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - return { by_token = {} } - end, - token_variants = function(token) - return { token } + new_cycle = function() + return {} end, targets_for_token = function() return targets @@ -252,7 +358,12 @@ describe('output token navigation', function() } package.loaded['opencode.ui.base_picker'] = { create_time_picker_item = function(text) - return { text = text } + return { + text = text, + to_string = function(self) + return self.text + end, + } end, pick = function(opts) picked_opts = opts @@ -261,17 +372,17 @@ describe('output token navigation', function() } navigation.navigate_to_location = function(path, line, col) navigated = { path = path, line = line, col = col } + return true end vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) - navigation.jump_to_target_at_cursor() navigation.navigate_to_location = original_navigate_to_location - package.loaded['opencode.ui.reference_picker'] = original_reference_picker package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot package.loaded['opencode.ui.base_picker'] = original_base_picker + target_stub:revert() assert.same(targets, picked_opts.items) assert.equal('file', picked_opts.preview) @@ -283,121 +394,60 @@ describe('output token navigation', function() assert.same({ path = existing_path, line = 2, col = 1 }, navigated) end) - it('uses the symbol before a trailing prose colon', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] + it('does not mutate target lifecycle when a picked symbol target fails to open', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local original_base_picker = package.loaded['opencode.ui.base_picker'] local original_navigate_to_location = navigation.navigate_to_location - local navigated - - package.loaded['opencode.ui.reference_picker'] = { - collect_refs = function() - return { { file_path = existing_path } } - end, + local dirty_stub = stub(renderer, 'mark_part_dirty') + local source_target = { + kind = 'symbol', + token = 'foo', + candidate_files = { existing_path }, + part_id = 'part_1', + message_id = 'msg_1', } - package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - return { by_token = {} } - end, - token_variants = function(token) - assert.equal('foo', token) - return { token } - end, - targets_for_token = function(_, token) - if token == 'foo' then - return { { token = 'foo', path = existing_path, line = 3, col = 1 } } - end - return {} - end, + local targets = { + { token = 'foo', path = existing_path, line = 1, col = 1 }, + { token = 'foo', path = existing_path, line = 2, col = 1 }, } - navigation.navigate_to_location = function(path, line, col) - navigated = { path = path, line = line, col = col } - end - - local line = 'foo: call this' - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) - set_cursor_on(output_win, 1, line, 'foo') - - navigation.jump_to_target_at_cursor() - - navigation.navigate_to_location = original_navigate_to_location - package.loaded['opencode.ui.reference_picker'] = original_reference_picker - package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot - - assert.same({ path = existing_path, line = 3, col = 1 }, navigated) - end) + local target_stub = stub(renderer, 'get_target_at_position').returns(source_target) - it('does not treat a prose colon as part of the symbol token', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] - local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] - local notify_stub = stub(vim, 'notify') - - package.loaded['opencode.ui.reference_picker'] = { - collect_refs = function() - return { { file_path = existing_path } } - end, - } package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - return { by_token = {} } - end, - token_variants = function(token) - error('symbol fallback should not run for cursor on prose colon: ' .. token) - end, - targets_for_token = function() + new_cycle = function() return {} end, - } - - local line = 'Note: plain text' - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) - set_cursor_on(output_win, 1, line, ':') - - navigation.jump_to_target_at_cursor() - - package.loaded['opencode.ui.reference_picker'] = original_reference_picker - package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot - - assert.stub(notify_stub).was_not_called() - notify_stub:revert() - end) - - it('notifies on symbol fallback miss without moving the cursor or window', function() - local original_reference_picker = package.loaded['opencode.ui.reference_picker'] - local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] - local notify_stub = stub(vim, 'notify') - - package.loaded['opencode.ui.reference_picker'] = { - collect_refs = function() - return {} + targets_for_token = function() + return targets end, } - package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - return { by_token = {} } - end, - token_variants = function(token) - return { token } + package.loaded['opencode.ui.base_picker'] = { + create_time_picker_item = function(text) + return { + text = text, + to_string = function(self) + return self.text + end, + } end, - targets_for_token = function() - return {} + pick = function(opts) + opts.callback(opts.items[2]) end, } + navigation.navigate_to_location = function() + return false + end - vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'plain' }) + vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { 'foo' }) vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) - local before_win = vim.api.nvim_get_current_win() - local before_cursor = vim.api.nvim_win_get_cursor(output_win) - navigation.jump_to_target_at_cursor() - package.loaded['opencode.ui.reference_picker'] = original_reference_picker + navigation.navigate_to_location = original_navigate_to_location package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + package.loaded['opencode.ui.base_picker'] = original_base_picker + target_stub:revert() - assert.equals(before_win, vim.api.nvim_get_current_win()) - assert.same(before_cursor, vim.api.nvim_win_get_cursor(output_win)) - assert.stub(notify_stub).was_called_with('No symbol target found: plain', vim.log.levels.INFO) - - notify_stub:revert() + assert.stub(dirty_stub).was_not_called() + dirty_stub:revert() end) it('opens explicit locations with 1-based col converted and clamped', function() @@ -411,28 +461,36 @@ describe('output token navigation', function() assert.equals(math.max(#line - 1, 0), cursor[2]) end) - it('keeps file-first without reading symbol fallback state', function() + it('keeps file-first without running the symbol resolver', function() local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] local original_navigate_to_location = navigation.navigate_to_location local navigated + local target_stub = stub(renderer, 'get_target_at_position').returns({ + kind = 'file', + path = existing_path, + line = 7, + col = 2, + }) package.loaded['opencode.ui.symbol_snapshot'] = { - collect = function() - error('symbol fallback should not run when a file target exists') + new_cycle = function() + error('symbol resolver should not run when a file target exists') end, } navigation.navigate_to_location = function(path, line, col) navigated = { path = path, line = line, col = col } + return true end local line = 'open ' .. existing_path .. ':7:2' vim.api.nvim_buf_set_lines(output_buf, 0, -1, false, { line }) - set_cursor_on(output_win, 1, line, existing_path) + vim.api.nvim_win_set_cursor(output_win, { 1, 0 }) navigation.jump_to_target_at_cursor() navigation.navigate_to_location = original_navigate_to_location package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + target_stub:revert() assert.same({ path = existing_path, line = 7, col = 2 }, navigated) end) diff --git a/tests/unit/output_spec.lua b/tests/unit/output_spec.lua new file mode 100644 index 00000000..2844333e --- /dev/null +++ b/tests/unit/output_spec.lua @@ -0,0 +1,44 @@ +local Output = require('opencode.ui.output') + +describe('Output targets', function() + it('initializes and appends targets', function() + local output = Output.new() + + output:add_target({ + kind = 'file', + path = 'README.md', + range = { line = 1, start_col = 0, end_col = 9 }, + }) + output:add_targets({ + { + kind = 'symbol', + token = 'setup', + candidate_files = { 'README.md' }, + range = { line = 1, start_col = 10, end_col = 15 }, + }, + }) + + assert.equals(2, #output.targets) + assert.equals('README.md', output.targets[1].path) + assert.equals('setup', output.targets[2].token) + end) + + it('clears targets with the rest of the carrier data', function() + local output = Output.new() + output:add_line('README.md') + output:add_extmark(1, { hl_group = 'OpencodeFile' }) + output:add_action({ text = 'Open', type = 'diff_open', key = 'o' }) + output:add_target({ + kind = 'file', + path = 'README.md', + range = { line = 1, start_col = 0, end_col = 9 }, + }) + + output:clear() + + assert.equals(0, #output.lines) + assert.is_true(vim.tbl_isempty(output.extmarks)) + assert.equals(0, #output.actions) + assert.equals(0, #output.targets) + end) +end) diff --git a/tests/unit/reference_facts_spec.lua b/tests/unit/reference_facts_spec.lua new file mode 100644 index 00000000..52c0705d --- /dev/null +++ b/tests/unit/reference_facts_spec.lua @@ -0,0 +1,325 @@ +local assert = require('luassert') +local stub = require('luassert.stub') + +describe('opencode.ui.reference_facts', function() + local reference_facts + local original_fn + local original_api + + local function assistant_message(id, session_id, parts) + return { + info = { id = id, role = 'assistant', sessionID = session_id }, + parts = parts or {}, + } + end + + before_each(function() + original_fn = vim.fn + original_api = vim.api + + vim.fn = vim.tbl_extend('force', vim.fn or {}, { + getcwd = function() + return '/repo' + end, + filereadable = function(path) + return (path == '/repo/src/ok.lua' or path == '/repo/src/tool.lua') and 1 or 0 + end, + fnamemodify = function(path, modifier) + if modifier == ':~:.' then + return path:gsub('^/repo/', '') + end + return path + end, + }) + + package.loaded['opencode.ui.reference_facts'] = nil + package.loaded['opencode.ui.reference_parser'] = nil + reference_facts = require('opencode.ui.reference_facts') + end) + + after_each(function() + reference_facts.clear() + vim.fn = original_fn + vim.api = original_api + package.loaded['opencode.ui.reference_facts'] = nil + package.loaded['opencode.ui.reference_parser'] = nil + end) + + it('owns session facts without loading the picker UI', function() + package.loaded['opencode.ui.reference_picker'] = false + + assert.has_no.errors(function() + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + }), + }) + end) + + package.loaded['opencode.ui.reference_picker'] = nil + assert.equal('src/ok.lua', reference_facts.current_refs()[1].path) + end) + + it('rebuilds current session assistant reference facts only', function() + reference_facts.rebuild('ses_1', { + { + info = { id = 'user_1', role = 'user', sessionID = 'ses_1' }, + parts = { { id = 'user_part', type = 'text', text = 'Ignore `src/user.lua`.' } }, + }, + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua:12:3`.' }, + { id = 'part_2', type = 'tool', state = { input = { filePath = '/repo/src/tool.lua' } } }, + }), + assistant_message('msg_2', 'ses_other', { + { id = 'part_other', type = 'text', text = 'Ignore `src/other.lua`.' }, + }), + }) + + local refs = reference_facts.current_refs() + + assert.equal(2, #refs) + assert.equal('src/ok.lua', refs[1].path) + assert.equal(12, refs[1].line) + assert.equal(3, refs[1].col) + assert.equal('assistant_text', refs[1].source_kind) + assert.are.same({ start_offset = 5, end_offset = 21 }, refs[1].raw_range) + assert.equal('src/tool.lua', refs[2].path) + assert.equal('tool_file_path', refs[2].source_kind) + end) + + it('replace_part replaces old refs for the same part', function() + local message = assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + }) + reference_facts.rebuild('ses_1', { message }) + + message.parts[1] = { id = 'part_1', type = 'text', text = 'See `src/loaded.lua`.' } + local changed = reference_facts.replace_part('ses_1', message, message.parts[1]) + local refs = reference_facts.current_refs() + + assert.is_true(changed) + assert.equal(1, #refs) + assert.equal('src/loaded.lua', refs[1].path) + end) + + it('replace_part keeps same-key append facts and adds new refs', function() + local message = assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + }) + reference_facts.rebuild('ses_1', { message }) + local first_range = reference_facts.current_refs()[1].raw_range + + message.parts[1] = { id = 'part_1', type = 'text', text = 'See `src/ok.lua`. Also `src/loaded.lua`.' } + local changed = reference_facts.replace_part('ses_1', message, message.parts[1]) + local refs = reference_facts.current_refs() + + assert.is_true(changed) + assert.equal(2, #refs) + assert.equal('src/ok.lua', refs[1].path) + assert.are.same(first_range, refs[1].raw_range) + assert.equal('src/loaded.lua', refs[2].path) + end) + + it('keeps duplicate path and line facts from different source parts and messages in session order', function() + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'First `src/ok.lua:12`.' }, + { id = 'part_2', type = 'text', text = 'Second `src/ok.lua:12`.' }, + }), + assistant_message('msg_2', 'ses_1', { + { id = 'part_3', type = 'text', text = 'Third `src/ok.lua:12`.' }, + }), + }) + + local refs = reference_facts.current_refs() + + assert.equal(3, #refs) + assert.equal('msg_1', refs[1].message_id) + assert.equal('part_1', refs[1].part_id) + assert.equal('msg_1', refs[2].message_id) + assert.equal('part_2', refs[2].part_id) + assert.equal('msg_2', refs[3].message_id) + assert.equal('part_3', refs[3].part_id) + assert.is_true(refs[1].order < refs[2].order) + assert.is_true(refs[2].order < refs[3].order) + end) + + it('remove_part and remove_message shrink current refs', function() + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + { id = 'part_2', type = 'text', text = 'See `src/loaded.lua`.' }, + }), + }) + + assert.is_true(reference_facts.remove_part('msg_1', 'part_1')) + assert.equal('src/loaded.lua', reference_facts.current_refs()[1].path) + + assert.is_true(reference_facts.remove_message('msg_1')) + assert.are.same({}, reference_facts.current_refs()) + end) + + it('maintains current_files from readable files', function() + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`, `src/loaded.lua`, and `src/missing.lua`.' }, + { id = 'part_2', type = 'text', text = 'See `src/ok.lua` again.' }, + }), + }) + + assert.are.same({ '/repo/src/ok.lua' }, reference_facts.current_files()) + end) + + it('refreshes current_files when filesystem availability changes', function() + local ok_exists = true + vim.fn.filereadable = function(path) + return (ok_exists and path == '/repo/src/ok.lua') and 1 or 0 + end + + reference_facts.rebuild('ses_1', { + assistant_message('msg_1', 'ses_1', { + { id = 'part_1', type = 'text', text = 'See `src/ok.lua`.' }, + }), + }) + + assert.are.same({ '/repo/src/ok.lua' }, reference_facts.current_files()) + + ok_exists = false + reference_facts.refresh_current_files() + + assert.are.same({}, reference_facts.current_files()) + end) +end) + +describe('reference facts renderer dirty propagation', function() + local state = require('opencode.state') + local ctx = require('opencode.ui.renderer.ctx') + local flush = require('opencode.ui.renderer.flush') + local events + local reference_facts + local schedule_stub + + local function message_with_refs() + return { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { + { id = 'part_ref', messageID = 'msg_1', sessionID = 'ses_1', type = 'text', text = 'See `src/ok.lua`.' }, + { id = 'part_later', messageID = 'msg_1', sessionID = 'ses_1', type = 'text', text = 'Call foo after refs.' }, + }, + } + end + + local function render_message_parts(message) + state.renderer.set_messages({ message }) + ctx.render_state:set_message(message) + ctx.render_state:set_part(message.parts[1], 1, 1) + ctx.render_state:set_part(message.parts[2], 2, 2) + end + + before_each(function() + package.loaded['opencode.ui.reference_facts'] = nil + package.loaded['opencode.ui.renderer.events'] = nil + reference_facts = require('opencode.ui.reference_facts') + events = require('opencode.ui.renderer.events') + ctx:reset() + reference_facts.clear() + state.session.set_active({ id = 'ses_1' }) + schedule_stub = stub(flush, 'schedule') + end) + + after_each(function() + schedule_stub:revert() + ctx:reset() + reference_facts.clear() + package.loaded['opencode.ui.renderer.events'] = nil + package.loaded['opencode.ui.reference_facts'] = nil + state.session.clear_active() + state.renderer.set_messages({}) + end) + + it('dirties following assistant text parts when a ref-bearing part changes', function() + local message = message_with_refs() + state.renderer.set_messages({ message }) + reference_facts.rebuild('ses_1', { message }) + ctx.render_state:set_message(message) + ctx.render_state:set_part(message.parts[1], 1, 1) + ctx.render_state:set_part(message.parts[2], 2, 2) + + events.on_part_updated({ + part = { + id = 'part_ref', + messageID = 'msg_1', + sessionID = 'ses_1', + type = 'text', + text = 'Reference removed.', + }, + }) + + assert.equal('msg_1', ctx.pending.dirty_parts.part_ref) + assert.equal('msg_1', ctx.pending.dirty_parts.part_later) + end) + + it('dirties following assistant text parts when a ref-bearing part is removed', function() + local message = message_with_refs() + state.renderer.set_messages({ message }) + reference_facts.rebuild('ses_1', { message }) + ctx.render_state:set_message(message) + ctx.render_state:set_part(message.parts[1], 1, 1) + ctx.render_state:set_part(message.parts[2], 2, 2) + + events.on_part_removed({ sessionID = 'ses_1', messageID = 'msg_1', partID = 'part_ref' }) + + assert.is_true(ctx.pending.removed_parts.part_ref) + assert.equal('msg_1', ctx.pending.dirty_parts.part_later) + end) + + it('dirties rendered assistant text parts when files are edited', function() + local message = message_with_refs() + message.parts[#message.parts + 1] = { + id = 'part_hidden', + messageID = 'msg_1', + sessionID = 'ses_1', + type = 'text', + text = 'Unrendered text should wait for its normal render path.', + } + render_message_parts(message) + + local original_cmd = vim.cmd + local refresh_stub = stub(reference_facts, 'refresh_current_files') + local ok, err = pcall(function() + vim.cmd = function(command) + assert.equal('checktime', command) + end + + events.on_file_edited({ file = 'src/ok.lua' }) + + assert.stub(refresh_stub).was_called(1) + assert.equal('msg_1', ctx.pending.dirty_parts.part_ref) + assert.equal('msg_1', ctx.pending.dirty_parts.part_later) + assert.is_nil(ctx.pending.dirty_parts.part_hidden) + end) + vim.cmd = original_cmd + refresh_stub:revert() + if not ok then + error(err) + end + end) + + it('dirties rendered assistant text parts when watched files change', function() + local message = message_with_refs() + render_message_parts(message) + + local refresh_stub = stub(reference_facts, 'refresh_current_files') + local ok, err = pcall(function() + events.on_file_watcher_updated({ file = 'src/ok.lua', event = 'unlink' }) + + assert.stub(refresh_stub).was_called(1) + assert.equal('msg_1', ctx.pending.dirty_parts.part_ref) + assert.equal('msg_1', ctx.pending.dirty_parts.part_later) + end) + refresh_stub:revert() + if not ok then + error(err) + end + end) +end) diff --git a/tests/unit/reference_parser_spec.lua b/tests/unit/reference_parser_spec.lua new file mode 100644 index 00000000..a6c167bf --- /dev/null +++ b/tests/unit/reference_parser_spec.lua @@ -0,0 +1,176 @@ +local assert = require('luassert') + +describe('opencode.ui.reference_parser', function() + local reference_parser + local original_startswith + + before_each(function() + original_startswith = vim.startswith + + vim.startswith = function(str, prefix) + return str:sub(1, #prefix) == prefix + end + + package.loaded['opencode.ui.reference_parser'] = nil + reference_parser = require('opencode.ui.reference_parser') + reference_parser.clear_all() + end) + + after_each(function() + reference_parser.clear_all() + vim.startswith = original_startswith + package.loaded['opencode.ui.reference_parser'] = nil + end) + + it('parses backtick file references with line and column', function() + local refs = reference_parser.parse_references('Error at `src/handler.lua:10:5`.', 'part1') + + assert.equal(1, #refs) + assert.equal('src/handler.lua', refs[1].file_path) + assert.equal(10, refs[1].line) + assert.equal(5, refs[1].col) + assert.is_number(refs[1].match_start) + assert.is_number(refs[1].match_end) + end) + + it('parses file URIs, nested paths, and top-level file mentions', function() + local refs = + reference_parser.parse_references('Open file://src/config.lua, src/module/helper.lua:25, and README.md.', 'part1') + + assert.equal(3, #refs) + assert.equal('src/config.lua', refs[1].file_path) + assert.equal('src/module/helper.lua', refs[2].file_path) + assert.equal(25, refs[2].line) + assert.equal('README.md', refs[3].file_path) + end) + + it('rejects URL paths and extensionless paths', function() + local refs = reference_parser.parse_references('Visit https://example.com/file.lua and see `README`.', 'part1') + + assert.equal(0, #refs) + end) + + it('parses missing top-level file mentions without checking filesystem', function() + local refs = reference_parser.parse_references('Read missing.xyz.', 'part1') + + assert.equal(1, #refs) + assert.equal('missing.xyz', refs[1].file_path) + end) + + it('includes explicit references regardless of file availability', function() + local refs = reference_parser.parse_references('Create `newfile.xyz`.', 'part1') + + assert.equal(1, #refs) + assert.equal('newfile.xyz', refs[1].file_path) + end) + + it('ignores path-shaped text inside fenced code blocks', function() + local refs = reference_parser.parse_references('```bash\n./run_tests.sh\nsrc/main.lua\n```', 'part1') + + assert.equal(0, #refs) + end) + + it('parses prose and inline references outside fenced code blocks', function() + local refs = reference_parser.parse_references( + '```bash\n./run_tests.sh\nsrc/ignored.lua\n```\nRun `./run_tests.sh` and inspect src/main.lua.', + 'part1' + ) + + assert.equal(2, #refs) + assert.equal('./run_tests.sh', refs[1].file_path) + assert.equal('src/main.lua', refs[2].file_path) + end) + + it('keeps one reference per text range while allowing repeated paths', function() + local refs = + reference_parser.parse_references('Check file://src/main.lua, then `src/main.lua:10`, then main.lua:42.', 'part1') + + assert.equal(3, #refs) + table.sort(refs, function(a, b) + return a.match_start < b.match_start + end) + assert.equal('src/main.lua', refs[1].file_path) + assert.equal('src/main.lua', refs[2].file_path) + assert.equal(10, refs[2].line) + assert.equal('main.lua', refs[3].file_path) + assert.equal(42, refs[3].line) + end) + + it('keeps repeated mentions of the same path at distinct text positions', function() + local refs = reference_parser.parse_references( + 'Open lua/opencode/ui/formatter.lua first, then mention lua/opencode/ui/formatter.lua before format_part.', + 'part1' + ) + + assert.equal(2, #refs) + assert.equal('lua/opencode/ui/formatter.lua', refs[1].file_path) + assert.equal('lua/opencode/ui/formatter.lua', refs[2].file_path) + assert.is_true(refs[1].match_start < refs[2].match_start) + end) + + it('extends append-only updates without recreating existing refs', function() + local refs1 = reference_parser.parse_references('Check `src/main.lua`.', 'part1') + local count_before_append = #refs1 + local first_ref = refs1[1] + local first_range = vim.deepcopy(first_ref) + local refs2 = reference_parser.parse_references('Check `src/main.lua`. Also `lib/util.lua`.', 'part1') + + assert.equal(1, count_before_append) + assert.equal(2, #refs2) + assert.is_true(rawequal(first_ref, refs2[1])) + assert.are.same(first_range, refs2[1]) + assert.equal('lib/util.lua', refs2[2].file_path) + end) + + it('waits for closing backticks before caching a path inside a code span', function() + local partial_refs = reference_parser.parse_references('- `lua/opencode/event_manager.lua', 'part1') + local count_before_closing_backtick = #partial_refs + local refs = reference_parser.parse_references('- `lua/opencode/event_manager.lua`', 'part1') + + assert.equal(0, count_before_closing_backtick) + assert.equal(1, #refs) + assert.equal('lua/opencode/event_manager.lua', refs[1].file_path) + assert.are.same( + { match_start = 3, match_end = 34 }, + { match_start = refs[1].match_start, match_end = refs[1].match_end } + ) + end) + + it('extends append-only references whose opening backtick is outside the overlap window', function() + local prefix = string.rep('a', 160) + local partial_text = prefix .. ' `src/' .. string.rep('deep/', 40) + reference_parser.parse_references(partial_text, 'part1') + + local refs = reference_parser.parse_references(partial_text .. 'main.lua`', 'part1') + + assert.equal(1, #refs) + assert.equal(prefix:len() + 2, refs[1].match_start) + assert.equal('src/' .. string.rep('deep/', 40) .. 'main.lua', refs[1].file_path) + end) + + it('resets same-key cache when text becomes shorter', function() + reference_parser.parse_references('Check `src/main.lua`. Also `lib/util.lua`.', 'part1') + + local refs = reference_parser.parse_references('No refs.', 'part1') + + assert.equal(0, #refs) + end) + + it('resets same-key cache when existing text changes in place', function() + local old_refs = reference_parser.parse_references('Check `src/main.lua`.', 'part1') + + local refs = reference_parser.parse_references('Check `src/other.lua`.', 'part1') + + reference_parser.clear('part1') + local refs_after_clear = reference_parser.parse_references('No refs.', 'part1') + + assert.equal(1, #old_refs) + assert.equal(1, #refs) + assert.equal('src/other.lua', refs[1].file_path) + assert.are.same( + { match_start = 7, match_end = 21 }, + { match_start = refs[1].match_start, match_end = refs[1].match_end } + ) + assert.equal(0, #refs_after_clear) + end) +end) diff --git a/tests/unit/reference_picker_spec.lua b/tests/unit/reference_picker_spec.lua index 4dba33d8..a8a40c78 100644 --- a/tests/unit/reference_picker_spec.lua +++ b/tests/unit/reference_picker_spec.lua @@ -2,10 +2,10 @@ local assert = require('luassert') describe('opencode.ui.reference_picker', function() local reference_picker - local mock_state local mock_config local mock_base_picker local mock_icons + local reference_facts local original_fn local original_startswith local original_cmd @@ -51,17 +51,6 @@ describe('opencode.ui.reference_picker', function() end, }) - mock_state = { - messages = {}, - event_manager = { - subscribe = function() end, - }, - store = { - subscribe = function() end, - }, - } - package.loaded['opencode.state'] = mock_state - mock_config = { ui = { picker_width = 100, @@ -91,6 +80,9 @@ describe('opencode.ui.reference_picker', function() package.loaded['opencode.ui.icons'] = mock_icons reference_picker = require('opencode.ui.reference_picker') + package.loaded['opencode.ui.reference_parser'] = nil + reference_facts = require('opencode.ui.reference_facts') + reference_facts.clear() end) after_each(function() @@ -100,213 +92,11 @@ describe('opencode.ui.reference_picker', function() vim.api = original_api package.loaded['opencode.ui.reference_picker'] = nil - package.loaded['opencode.state'] = nil package.loaded['opencode.config'] = nil package.loaded['opencode.ui.base_picker'] = nil package.loaded['opencode.ui.icons'] = nil - end) - - describe('parse_references', function() - it('parses backtick-wrapped file references', function() - local text = 'Check the implementation in `src/main.lua` for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/main.lua', refs[1].file_path) - assert.is_nil(refs[1].line) - assert.is_nil(refs[1].col) - end) - - it('parses backtick-wrapped file references with line numbers', function() - local text = 'See function at `src/utils.lua:42` for implementation.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/utils.lua', refs[1].file_path) - assert.equal(42, refs[1].line) - assert.is_nil(refs[1].col) - end) - - it('parses backtick-wrapped file references with line and column', function() - local text = 'Error at `src/handler.lua:10:5` needs fixing.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/handler.lua', refs[1].file_path) - assert.equal(10, refs[1].line) - assert.equal(5, refs[1].col) - end) - - it('parses backtick-wrapped file references with line ranges (only start line captured)', function() - local text = 'Review lines `src/test.lua:10-20` for context.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/test.lua', refs[1].file_path) - assert.equal(10, refs[1].line) - -- end of range is not represented in the ref struct - end) - - it('parses file:// URI references', function() - local text = 'Open file://src/config.lua for settings.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/config.lua', refs[1].file_path) - end) - - it('parses file:// URI references with line numbers', function() - local text = 'Check file://src/init.lua:99 for the issue.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/init.lua', refs[1].file_path) - assert.equal(99, refs[1].line) - end) - - it('parses plain path references with forward slashes', function() - local text = 'The function is in src/module/helper.lua:25' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/module/helper.lua', refs[1].file_path) - assert.equal(25, refs[1].line) - end) - - it('parses top-level file references when file exists', function() - local text = 'Check README.md for documentation.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('README.md', refs[1].file_path) - end) - - it('parses multiple references in one text', function() - local text = [[ - The main logic is in `src/main.lua:50` and helper - functions are in `lib/utils.lua:10`. Also see - the configuration in config.txt. - ]] - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(3, #refs) - assert.equal('src/main.lua', refs[1].file_path) - assert.equal(50, refs[1].line) - assert.equal('lib/utils.lua', refs[2].file_path) - assert.equal(10, refs[2].line) - assert.equal('config.txt', refs[3].file_path) - end) - - it('rejects URLs in context', function() - local text = 'Visit https://example.com/file.lua for more info.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(0, #refs) - end) - - it('rejects www URLs in context', function() - local text = 'See www.example.com/docs.txt for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(0, #refs) - end) - - it('rejects files without extensions', function() - local text = 'Check `README` for info.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(0, #refs) - end) - - it('rejects top-level files that do not exist (check_exists pattern)', function() - -- Unquoted top-level filenames require the file to be readable - local text = 'See nonexistent.xyz for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(0, #refs) - end) - - it('includes backtick-wrapped files regardless of existence', function() - -- Backtick pattern has check_exists=false; useful for referencing new files - local text = 'Create `newfile.xyz` with the following content.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('newfile.xyz', refs[1].file_path) - end) - - it('deduplicates overlapping matches across patterns', function() - local text = 'Check file://src/main.lua for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - -- file:// and plain-slash patterns both see this, but only the first match wins - assert.is_true(#refs >= 1) - assert.equal('src/main.lua', refs[1].file_path) - end) - - it('keeps later top-level references with the same basename', function() - local text = 'See `src/main.lua:10` first, then check main.lua:42 too.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(2, #refs) - assert.equal('src/main.lua', refs[1].file_path) - assert.equal(10, refs[1].line) - assert.equal('main.lua', refs[2].file_path) - assert.equal(42, refs[2].line) - end) - - it('ref struct contains file_path, line, col, match_start, match_end', function() - local text = 'See `src/test.lua:5:3` for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - local ref = refs[1] - assert.equal('src/test.lua', ref.file_path) - assert.equal(5, ref.line) - assert.equal(3, ref.col) - assert.is_number(ref.match_start) - assert.is_number(ref.match_end) - end) - - it('returns same cached refs when called again with identical text', function() - local text = 'Check `src/main.lua` for details.' - local refs1 = reference_picker.parse_references(text, 'msg1') - local refs2 = reference_picker.parse_references(text, 'msg1') - - assert.equal(#refs1, #refs2) - assert.equal(refs1[1].file_path, refs2[1].file_path) - end) - - it('extends refs incrementally as text grows', function() - local text1 = 'Check `src/main.lua`.' - local text2 = text1 .. ' Also `lib/util.lua`.' - - -- Capture count before second call: parse_references returns the live c.refs - -- table, so refs1 would mutate if stored and then text2 is parsed - local count1 = #reference_picker.parse_references(text1, 'msg1') - local count2 = #reference_picker.parse_references(text2, 'msg1') - - assert.equal(1, count1) - assert.equal(2, count2) - end) - - it('handles files with hyphens and underscores', function() - local text = 'Check `my-cool_file.lua:5` for details.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('my-cool_file.lua', refs[1].file_path) - assert.equal(5, refs[1].line) - end) - - it('handles nested directory paths', function() - local text = 'See `src/module/sub/deep/file.lua:100` for implementation.' - local refs = reference_picker.parse_references(text, 'msg1') - - assert.equal(1, #refs) - assert.equal('src/module/sub/deep/file.lua', refs[1].file_path) - assert.equal(100, refs[1].line) - end) + package.loaded['opencode.ui.reference_facts'] = nil + package.loaded['opencode.ui.reference_parser'] = nil end) describe('navigate_to', function() @@ -316,7 +106,7 @@ describe('opencode.ui.reference_picker', function() table.insert(cmd_calls, cmd) end - local ref = { file_path = 'src/main.lua' } + local ref = { path = 'src/main.lua' } reference_picker.navigate_to(ref) assert.equal(1, #cmd_calls) @@ -329,7 +119,7 @@ describe('opencode.ui.reference_picker', function() table.insert(cursor_calls, { win = win, pos = pos }) end - local ref = { file_path = 'src/main.lua', line = 42 } + local ref = { path = 'src/main.lua', line = 42 } reference_picker.navigate_to(ref) assert.equal(1, #cursor_calls) @@ -343,7 +133,7 @@ describe('opencode.ui.reference_picker', function() table.insert(cursor_calls, { win = win, pos = pos }) end - local ref = { file_path = 'src/main.lua', line = 42, col = 10 } + local ref = { path = 'src/main.lua', line = 42, col = 10 } reference_picker.navigate_to(ref) assert.equal(1, #cursor_calls) @@ -360,7 +150,7 @@ describe('opencode.ui.reference_picker', function() return 50 end - local ref = { file_path = 'src/main.lua', line = 999 } + local ref = { path = 'src/main.lua', line = 999 } reference_picker.navigate_to(ref) assert.equal(1, #cursor_calls) @@ -373,7 +163,7 @@ describe('opencode.ui.reference_picker', function() table.insert(cmd_calls, cmd) end - local ref = { file_path = 'src/my file.lua' } + local ref = { path = 'src/my file.lua' } reference_picker.navigate_to(ref) assert.equal(1, #cmd_calls) @@ -387,7 +177,7 @@ describe('opencode.ui.reference_picker', function() table.insert(notify_calls, { msg = msg, level = level }) end - local ref = { file_path = 'nonexistent.xyz' } + local ref = { path = 'nonexistent.xyz' } reference_picker.navigate_to(ref) assert.equal(1, #notify_calls) @@ -407,7 +197,7 @@ describe('opencode.ui.reference_picker', function() return 0 end - local ref = { file_path = '/absolute/path/file.lua' } + local ref = { path = '/absolute/path/file.lua' } reference_picker.navigate_to(ref) assert.equal(1, #cmd_calls) @@ -415,90 +205,10 @@ describe('opencode.ui.reference_picker', function() end) end) - -- Helper: populate state messages then expose items via pick() - local function pick_items(messages_and_texts) - -- messages_and_texts: list of { id, role, text, parts } - local state_msgs = {} - for _, m in ipairs(messages_and_texts) do - local parts = {} - if m.text then - table.insert(parts, { type = 'text', id = m.id .. ':text', text = m.text }) - end - for _, part in ipairs(m.parts or {}) do - table.insert(parts, part) - end - table.insert(state_msgs, { - info = { role = m.role or 'assistant', id = m.id }, - parts = parts, - }) - end - mock_state.messages = state_msgs - - local captured - mock_base_picker.pick = function(opts) - captured = opts - return {} - end - reference_picker.pick() - return captured and captured.items or nil + local function rebuild_facts(messages) + reference_facts.rebuild('ses_1', messages) end - describe('collect_refs', function() - it('rebuilds text refs from assistant message parts when parse cache is empty', function() - mock_state.messages = { - { - info = { role = 'assistant', id = 'msg1' }, - parts = { - { type = 'text', id = 'part1', text = 'Check `src/main.lua:10`.' }, - }, - }, - } - - local refs = reference_picker.collect_refs() - - assert.equal(1, #refs) - assert.equal('src/main.lua', refs[1].file_path) - assert.equal(10, refs[1].line) - end) - - it('collects tool part file paths', function() - mock_state.messages = { - { - info = { role = 'assistant', id = 'msg1' }, - parts = { - { - type = 'tool', - state = { input = { filePath = '/test/project/src/file.lua' } }, - }, - }, - }, - } - - local refs = reference_picker.collect_refs() - - assert.equal(1, #refs) - assert.equal('src/file.lua', refs[1].file_path) - end) - - it('keeps separate text parts in the same assistant message', function() - mock_state.messages = { - { - info = { role = 'assistant', id = 'msg1' }, - parts = { - { type = 'text', id = 'part1', text = 'Check `src/one.lua`.' }, - { type = 'text', id = 'part2', text = 'Then `src/two.lua`.' }, - }, - }, - } - - local refs = reference_picker.collect_refs() - - assert.equal(2, #refs) - assert.equal('src/one.lua', refs[1].file_path) - assert.equal('src/two.lua', refs[2].file_path) - end) - end) - describe('pick', function() it('shows notification when no references found', function() local notify_calls = {} @@ -507,7 +217,7 @@ describe('opencode.ui.reference_picker', function() table.insert(notify_calls, { msg = msg, level = level }) end - mock_state.messages = {} + reference_facts.clear() reference_picker.pick() assert.equal(1, #notify_calls) @@ -523,14 +233,14 @@ describe('opencode.ui.reference_picker', function() return {} end - mock_state.messages = { + rebuild_facts({ { - info = { role = 'assistant', id = 'msg1' }, + info = { role = 'assistant', id = 'msg1', sessionID = 'ses_1' }, parts = { { type = 'text', id = 'part1', text = 'Check `src/main.lua:10`.' }, }, }, - } + }) reference_picker.pick() assert.equal(1, #pick_calls) @@ -542,185 +252,51 @@ describe('opencode.ui.reference_picker', function() assert.equal('file', pick_calls[1].preview) end) - it('collects references from assistant message text parts', function() - local items = pick_items({ - { id = 'msg1', text = 'Check `src/main.lua:10` for details.' }, - }) - - assert.is_not_nil(items) - assert.equal(1, #items) - assert.equal('src/main.lua', items[1].file_path) - assert.equal(10, items[1].line) - end) - - it('ignores user messages when collecting refs', function() - mock_state.messages = { + it('uses references from reference_facts', function() + rebuild_facts({ { - info = { role = 'user', id = 'msg1' }, + info = { role = 'assistant', id = 'msg1', sessionID = 'ses_1' }, parts = { - { type = 'text', id = 'part1', text = 'Check `src/main.lua:10`.' }, + { type = 'text', id = 'part1', text = 'Check `src/main.lua:10` for details.' }, }, }, - } - - local notify_calls = {} - local original_notify = vim.notify - vim.notify = function(msg, level) - table.insert(notify_calls, { msg = msg, level = level }) + }) + local captured + mock_base_picker.pick = function(opts) + captured = opts + return {} end reference_picker.pick() - - assert.equal(1, #notify_calls) -- "No code references found" - vim.notify = original_notify - end) - - it('returns refs in reverse message order (most recent first)', function() - local items = pick_items({ - { id = 'msg1', text = 'Check `old.lua:1`.' }, - { id = 'msg2', text = 'See `new.lua:2`.' }, - }) - - assert.is_not_nil(items) - assert.equal(2, #items) - assert.equal('new.lua', items[1].file_path) - assert.equal('old.lua', items[2].file_path) - end) - - it('deduplicates refs with same file and line across messages', function() - local items = pick_items({ - { id = 'msg1', text = 'Check `src/main.lua:10`.' }, - { id = 'msg2', text = 'Also check `src/main.lua:10`.' }, - }) - - assert.is_not_nil(items) - assert.equal(1, #items) - end) - - it('keeps most recent ref when deduplicating (reverse order wins)', function() - -- msg2 is processed first (reverse order), so it wins deduplication - local items = pick_items({ - { id = 'msg1', text = 'Check `src/main.lua:10`.' }, - { id = 'msg2', text = 'Also check `src/main.lua:10`.' }, - }) - - assert.is_not_nil(items) - assert.equal(1, #items) - assert.equal('src/main.lua', items[1].file_path) - end) - - it('collects file paths from tool parts', function() - local items = pick_items({ - { - id = 'msg1', - parts = { - { - type = 'tool', - state = { input = { filePath = '/test/project/src/file.lua' } }, - }, - }, - }, - }) - - assert.is_not_nil(items) - assert.equal(1, #items) - assert.equal('src/file.lua', items[1].file_path) - end) - - it('deduplicates matching text refs and tool-part refs', function() - local items = pick_items({ - { - id = 'msg1', - text = 'Check `src/file.lua` for details.', - parts = { - { - type = 'tool', - state = { input = { filePath = '/test/project/src/file.lua' } }, - }, - }, - }, - }) + local items = captured and captured.items assert.is_not_nil(items) assert.equal(1, #items) - assert.equal('src/file.lua', items[1].file_path) + assert.equal('src/main.lua', items[1].path) + assert.equal(10, items[1].line) end) - it('ignores non-existent files in tool parts', function() - local notify_calls = {} - local original_notify = vim.notify - vim.notify = function(msg, level) - table.insert(notify_calls, { msg = msg, level = level }) - end - - pick_items({ + it('deduplicates picker display items by path and line without changing facts', function() + rebuild_facts({ { - id = 'msg1', + info = { role = 'assistant', id = 'msg1', sessionID = 'ses_1' }, parts = { - { - type = 'tool', - state = { input = { filePath = '/test/project/nonexistent.xyz' } }, - }, + { type = 'text', id = 'part1', text = 'First `src/main.lua:10`.' }, + { type = 'text', id = 'part2', text = 'Second `src/main.lua:10`.' }, }, }, }) - - assert.equal(1, #notify_calls) -- "No code references found" - vim.notify = original_notify - end) - - it('collects refs from nil messages gracefully', function() - mock_state.messages = nil - - local notify_calls = {} - local original_notify = vim.notify - vim.notify = function(msg, level) - table.insert(notify_calls, { msg = msg, level = level }) + local captured + mock_base_picker.pick = function(opts) + captured = opts + return {} end reference_picker.pick() - assert.equal(1, #notify_calls) - assert.equal('No code references found in the conversation', notify_calls[1].msg) - vim.notify = original_notify - end) - end) - - describe('setup', function() - it('can be called without errors', function() - assert.has_no.errors(function() - reference_picker.setup() - end) - end) - - it('subscribes to messages state changes', function() - local subscriptions = {} - mock_state.store.subscribe = function(key, handler) - table.insert(subscriptions, { key = key, handler = handler }) - end - - reference_picker.setup() - - assert.equal(1, #subscriptions) - assert.equal('messages', subscriptions[1].key) - assert.is_function(subscriptions[1].handler) - end) - - it('clears the parse cache when messages state changes', function() - reference_picker.parse_references('See `src/main.lua`.', 'msg1') - - local handler - mock_state.store.subscribe = function(key, h) - handler = h - end - reference_picker.setup() - - -- Simulate a messages state change - handler() - - local refs = reference_picker.parse_references('No refs.', 'msg1') - - assert.equal(0, #refs) + assert.equal(1, #captured.items) + assert.equal('part1', captured.items[1].part_id) + assert.equal('Code References (1)', captured.title) end) end) end) diff --git a/tests/unit/render_state_spec.lua b/tests/unit/render_state_spec.lua index 8b70898b..14277021 100644 --- a/tests/unit/render_state_spec.lua +++ b/tests/unit/render_state_spec.lua @@ -264,6 +264,133 @@ describe('RenderState', function() end) end) + describe('targets', function() + local function target(kind, line, start_col, end_col, extra) + local result = vim.tbl_extend('force', { + kind = kind, + range = { + line = line, + start_col = start_col, + end_col = end_col, + }, + }, extra or {}) + return result + end + + before_each(function() + render_state:set_part({ id = 'part1', messageID = 'msg1' }, 0, 2) + end) + + it('adds and gets targets by line and column', function() + render_state:add_targets('part1', { + target('file', 1, 3, 12, { path = 'lua/opencode/init.lua' }), + }) + + local result = render_state:get_target_at_position(1, 3) + + assert.is_not_nil(result) + assert.equals('file', result.kind) + assert.equals('part1', result.part_id) + assert.equals('msg1', result.message_id) + assert.equals('lua/opencode/init.lua', result.path) + assert.is_nil(render_state:get_target_at_position(1, 12)) + end) + + it('applies output line offset when adding targets', function() + render_state:add_targets('part1', { + target('file', 2, 0, 4, { path = 'README.md' }), + }, 10) + + assert.is_nil(render_state:get_target_at_position(2, 1)) + + local result = render_state:get_target_at_position(12, 1) + assert.is_not_nil(result) + assert.equals('README.md', result.path) + end) + + it('clears all targets for a part', function() + render_state:add_targets('part1', { + target('file', 1, 0, 4, { path = 'README.md' }), + target('symbol', 1, 5, 9, { token = 'setup', candidate_files = { 'README.md' } }), + }) + + render_state:clear_targets('part1') + + assert.is_nil(render_state:get_target_at_position(1, 1)) + assert.is_nil(render_state:get_target_at_position(1, 6)) + end) + + it('leaves no target after clear followed by empty add', function() + render_state:add_targets('part1', { + target('file', 1, 0, 4, { path = 'README.md' }), + }) + + render_state:clear_targets('part1') + render_state:add_targets('part1', {}, 10) + + assert.is_nil(render_state:get_target_at_position(1, 1)) + end) + + it('filters targets without changing file and diff priority over symbols', function() + render_state:add_targets('part1', { + target('symbol', 1, 0, 10, { token = 'setup', candidate_files = { 'README.md' } }), + target('diff', 1, 0, 10, { path = 'README.md', line = 3 }), + }) + + local result = render_state:get_target_at_position(1, 4) + assert.equals('diff', result.kind) + + local symbol = render_state:get_target_at_position(1, 4, function(candidate) + return candidate.kind == 'symbol' + end) + assert.equals('symbol', symbol.kind) + end) + + it('moves targets with shifted parts', function() + render_state:set_part({ id = 'part2', messageID = 'msg1' }, 3, 4) + render_state:add_targets('part2', { + target('file', 4, 0, 6, { path = 'later.lua' }), + }) + + render_state:shift_all(3, 5) + + assert.is_nil(render_state:get_target_at_position(4, 1)) + local shifted = render_state:get_target_at_position(9, 1) + assert.is_not_nil(shifted) + assert.equals('part2', shifted.part_id) + end) + + it('moves targets when a part line range is updated', function() + render_state:add_targets('part1', { + target('file', 1, 0, 6, { path = 'moved.lua' }), + }) + + render_state:update_part_lines('part1', 5, 7) + + assert.is_nil(render_state:get_target_at_position(1, 1)) + local shifted = render_state:get_target_at_position(6, 1) + assert.is_not_nil(shifted) + assert.equals('moved.lua', shifted.path) + end) + + it('removes targets with the removed part and shifts remaining part targets', function() + render_state:set_part({ id = 'part2', messageID = 'msg1' }, 3, 4) + render_state:add_targets('part1', { + target('file', 1, 8, 14, { path = 'removed.lua' }), + }) + render_state:add_targets('part2', { + target('file', 4, 0, 6, { path = 'kept.lua' }), + }) + + render_state:remove_part('part1') + + assert.is_nil(render_state:get_target_at_position(1, 9)) + local shifted = render_state:get_target_at_position(1, 1) + assert.is_not_nil(shifted) + assert.equals('kept.lua', shifted.path) + end) + end) + describe('update_part_lines', function() before_each(function() state.renderer.set_messages({ diff --git a/tests/unit/renderer_buffer_spec.lua b/tests/unit/renderer_buffer_spec.lua index 6dcebbb3..3625a296 100644 --- a/tests/unit/renderer_buffer_spec.lua +++ b/tests/unit/renderer_buffer_spec.lua @@ -158,6 +158,43 @@ describe('renderer.buffer extmarks', function() }, }, 12) end) + + it('replaces rendered targets with line offset when updating a part', function() + ctx.render_state:set_part({ id = 'part_1', messageID = 'msg_1', type = 'text' }, 10, 10) + ctx.render_state:add_targets('part_1', { + { + kind = 'file', + path = 'old.lua', + range = { line = 11, start_col = 0, end_col = 7 }, + }, + }) + + buffer.upsert_part_now('part_1', 'msg_1', { + lines = { 'new.lua' }, + extmarks = {}, + actions = {}, + targets = { + { + kind = 'file', + path = 'new.lua', + range = { line = 1, start_col = 0, end_col = 7 }, + }, + }, + }, { + lines = { 'old.lua' }, + extmarks = {}, + actions = {}, + targets = {}, + }) + + assert.is_nil(ctx.render_state:get_target_at_position(11, 1, function(target) + return target.path == 'old.lua' + end)) + + local result = ctx.render_state:get_target_at_position(11, 1) + assert.is_not_nil(result) + assert.equals('new.lua', result.path) + end) end) describe('update_part_folds', function() diff --git a/tests/unit/renderer_targets_spec.lua b/tests/unit/renderer_targets_spec.lua new file mode 100644 index 00000000..99328903 --- /dev/null +++ b/tests/unit/renderer_targets_spec.lua @@ -0,0 +1,123 @@ +local ctx = require('opencode.ui.renderer.ctx') +local renderer = require('opencode.ui.renderer') +local flush = require('opencode.ui.renderer.flush') +local stub = require('luassert.stub') +local helpers = require('tests.helpers') +local state = require('opencode.state') + +describe('renderer target API', function() + local schedule_stub + + before_each(function() + ctx:reset() + schedule_stub = stub(flush, 'schedule') + end) + + after_each(function() + schedule_stub:revert() + ctx:reset() + end) + + it('returns rendered targets with source ids', function() + ctx.render_state:set_part({ id = 'part1', messageID = 'msg1' }, 0, 0) + ctx.render_state:add_targets('part1', { + { + kind = 'file', + path = 'README.md', + range = { line = 1, start_col = 0, end_col = 9 }, + }, + }) + + local result = renderer.get_target_at_position(1, 4) + + assert.is_not_nil(result) + assert.equals('README.md', result.path) + assert.equals('part1', result.part_id) + assert.equals('msg1', result.message_id) + end) + + it('marks a part dirty using part_id then message_id', function() + renderer.mark_part_dirty('part1', 'msg1') + + assert.equals('msg1', ctx.pending.dirty_parts.part1) + assert.equals('part1', ctx.pending.dirty_part_order[1]) + assert.is_true(ctx.pending.dirty_part_by_message.msg1.part1) + end) +end) + +describe('renderer flush formatter context', function() + local formatter + local reference_facts + local symbol_snapshot + local format_stub + local refs_stub + local files_stub + local cycle_stub + + before_each(function() + helpers.replay_setup() + ctx:reset() + formatter = require('opencode.ui.formatter') + reference_facts = require('opencode.ui.reference_facts') + symbol_snapshot = require('opencode.ui.symbol_snapshot') + end) + + after_each(function() + if format_stub then + format_stub:revert() + end + if refs_stub then + refs_stub:revert() + end + if files_stub then + files_stub:revert() + end + if cycle_stub then + cycle_stub:revert() + end + ctx:reset() + if state.windows then + require('opencode.ui.ui').close_windows(state.windows) + end + end) + + it('creates one symbol cycle and shares it across formatted parts', function() + local Output = require('opencode.ui.output') + local cycle = { id = 'cycle_1' } + local contexts = {} + + refs_stub = stub(reference_facts, 'current_refs').returns({}) + files_stub = stub(reference_facts, 'current_files').returns({ '/repo/src/ok.lua' }) + cycle_stub = stub(symbol_snapshot, 'new_cycle').returns(cycle) + format_stub = stub(formatter, 'format_part').invokes(function(_, _, _, context) + contexts[#contexts + 1] = context + local output = Output.new() + output:add_line('formatted') + return output + end) + + local message = { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { + { id = 'part_1', messageID = 'msg_1', sessionID = 'ses_1', type = 'text', text = 'one' }, + { id = 'part_2', messageID = 'msg_1', sessionID = 'ses_1', type = 'text', text = 'two' }, + }, + } + ctx.render_state:set_message(message) + ctx.render_state:set_part(message.parts[1], 1, 1) + ctx.render_state:set_part(message.parts[2], 2, 2) + ctx.render_state:upsert_child_session_part('child_1', { id = 'child_part', type = 'tool' }) + ctx.pending.dirty_part_order = { 'part_1', 'part_2' } + ctx.pending.dirty_parts = { part_1 = 'msg_1', part_2 = 'msg_1' } + + flush.flush() + + assert.stub(cycle_stub).was_called(1) + assert.equal(2, #contexts) + assert.is_true(contexts[1].interactive) + assert.is_function(contexts[1].get_child_parts) + assert.are.same(ctx.render_state:get_child_session_parts('child_1'), contexts[1].get_child_parts('child_1')) + assert.are.equal(cycle, contexts[1].symbol_cycle) + assert.are.equal(contexts[1].symbol_cycle, contexts[2].symbol_cycle) + end) +end) diff --git a/tests/unit/session_picker_spec.lua b/tests/unit/session_picker_spec.lua index f3c2eb3a..d37ca333 100644 --- a/tests/unit/session_picker_spec.lua +++ b/tests/unit/session_picker_spec.lua @@ -40,7 +40,13 @@ describe('opencode.ui.session_picker', function() it('returns false when only a sibling is deleted', function() local sibling = { id = 'sibling', parentID = 'root' } - assert.is_false(session_picker._is_session_or_ancestor_deleted('child', { sibling = true }, { root, child, sibling, grandchild })) + assert.is_false( + session_picker._is_session_or_ancestor_deleted( + 'child', + { sibling = true }, + { root, child, sibling, grandchild } + ) + ) end) it('returns false for a root session when an unrelated root is deleted', function() @@ -106,6 +112,126 @@ describe('opencode.ui.session_picker', function() assert.are.same({ 'Loading...' }, writes[1]) assert.are.same({ 'No messages or failed to load' }, writes[2]) end) + + it('formats preview parts with non-interactive formatter context', function() + local base_picker = require('opencode.ui.base_picker') + local formatter = require('opencode.ui.formatter') + local Output = require('opencode.ui.output') + local captured_opts + local contexts = {} + local format_stub = stub(formatter, 'format_part').invokes(function(_, _, _, context) + contexts[#contexts + 1] = context + local output = Output.new() + output:add_line('preview part') + return output + end) + + base_picker.pick = function(opts) + captured_opts = opts + return true + end + + state.jobs.set_api_client({ + list_messages = function() + return Promise.new():resolve({ + { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { + { id = 'part_1', type = 'text', text = 'See `src/main.lua`.' }, + }, + }, + }) + end, + }) + + session_picker.pick({ { id = 's1', title = 'Session', time = { updated = 'now' } } }, function() end) + + local target = { + get_bufnr = function() + return nil + end, + is_valid = function() + return true + end, + set_lines = function() end, + with_window = function() end, + } + + captured_opts.preview_fn({ id = 's1' }, target) + vim.wait(100, function() + return #contexts == 1 + end) + + format_stub:revert() + + assert.equal(1, #contexts) + assert.is_false(contexts[1].interactive) + assert.is_nil(contexts[1].get_child_parts) + assert.is_nil(contexts[1].symbol_cycle) + end) + + it('does not resolve rendered targets while formatting preview parts', function() + local base_picker = require('opencode.ui.base_picker') + local original_symbol_snapshot = package.loaded['opencode.ui.symbol_snapshot'] + local captured_opts + local writes = {} + local bufnr = vim.api.nvim_create_buf(false, true) + + package.loaded['opencode.ui.symbol_snapshot'] = { + new_cycle = function() + error('preview formatting must not create a symbol cycle') + end, + targets_for_token = function() + error('preview formatting must not resolve symbol targets') + end, + } + + base_picker.pick = function(opts) + captured_opts = opts + return true + end + + state.jobs.set_api_client({ + list_messages = function() + return Promise.new():resolve({ + { + info = { id = 'msg_1', role = 'assistant', sessionID = 'ses_1' }, + parts = { + { id = 'part_1', type = 'text', text = 'See `src/main.lua` then call foo.' }, + }, + }, + }) + end, + }) + + session_picker.pick({ { id = 's1', title = 'Session', time = { updated = 'now' } } }, function() end) + + local target = { + get_bufnr = function() + return bufnr + end, + is_valid = function() + return true + end, + set_lines = function(_, lines) + writes[#writes + 1] = lines + end, + with_window = function(_, fn) + fn() + end, + } + + captured_opts.preview_fn({ id = 's1' }, target) + vim.wait(100, function() + return #writes >= 2 + end) + + package.loaded['opencode.ui.symbol_snapshot'] = original_symbol_snapshot + pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) + + assert.is_truthy(table.concat(writes[#writes], '\n'):find('src/main.lua', 1, true)) + assert.is_nil(table.concat(writes[#writes], '\n'):find('%[render error%]')) + end) end) -- ----------------------------------------------------------------------- @@ -119,12 +245,15 @@ describe('opencode.ui.session_picker', function() local root_session = { id = 'root', parentID = nil, title = 'Root', time = { updated = '2024-01-01' } } local other_root = { id = 'other-root', parentID = nil, title = 'Other', time = { updated = '2024-01-01' } } local child_session = { id = 'child', parentID = 'root', title = 'Child', time = { updated = '2024-01-01' } } - local grandchild_session = { id = 'grandchild', parentID = 'child', title = 'Grandchild', time = { updated = '2024-01-01' } } + local grandchild_session = + { id = 'grandchild', parentID = 'child', title = 'Grandchild', time = { updated = '2024-01-01' } } before_each(function() original = support.snapshot_state() - vim.schedule = function(fn) fn() end + vim.schedule = function(fn) + fn() + end support.mock_api_client() diff --git a/tests/unit/symbol_snapshot_spec.lua b/tests/unit/symbol_snapshot_spec.lua index d4b6364d..5ad84495 100644 --- a/tests/unit/symbol_snapshot_spec.lua +++ b/tests/unit/symbol_snapshot_spec.lua @@ -10,6 +10,8 @@ describe('opencode.ui.symbol_snapshot', function() local files local buffers local captures_by_content + local read_counts + local parse_counts local query_available local parser_available local notify_calls @@ -37,6 +39,8 @@ describe('opencode.ui.symbol_snapshot', function() files = {} buffers = {} captures_by_content = {} + read_counts = {} + parse_counts = {} query_available = true parser_available = true notify_calls = {} @@ -52,6 +56,7 @@ describe('opencode.ui.symbol_snapshot', function() if not files[path] then error('missing file') end + read_counts[path] = (read_counts[path] or 0) + 1 return files[path] end, bufnr = function(path) @@ -133,6 +138,7 @@ describe('opencode.ui.symbol_snapshot', function() end return { parse = function() + parse_counts[content] = (parse_counts[content] or 0) + 1 return { { root = function() @@ -149,6 +155,7 @@ describe('opencode.ui.symbol_snapshot', function() end return { parse = function() + parse_counts[bufnr] = (parse_counts[bufnr] or 0) + 1 return { { root = function() @@ -188,7 +195,55 @@ describe('opencode.ui.symbol_snapshot', function() end table.sort(keys) - assert.same({ 'collect', 'has_token', 'targets_for_token', 'token_variants' }, keys) + assert.same({ 'new_cycle', 'targets_for_token', 'token_variants' }, keys) + end) + + it('bounds token lookup to candidate files', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + set_file('/test/project/src/other.lua', { 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 0, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'bar', { '/test/project/src/main.lua' })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'bar', { '/test/project/src/other.lua' })) + end) + + it('parses each candidate file once per cycle', function() + set_file('/test/project/src/main.lua', { 'local function foo() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + + set_file('/test/project/src/main.lua', { 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 0, 15) }, + }) + + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'bar', { '/test/project/src/main.lua' })) + end) + + it('reads and parses the same candidate file once per cycle', function() + local path = '/test/project/src/main.lua' + local content = 'local function foo() end\nlocal function bar() end' + set_file(path, { 'local function foo() end', 'local function bar() end' }, { + { id = 1, node = fake_node('foo', 0, 15) }, + { id = 1, node = fake_node('bar', 1, 15) }, + }) + + local cycle = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { path })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'bar', { path })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { path, path })) + + assert.equal(1, read_counts[path]) + assert.equal(1, parse_counts[content]) end) it('collects definition tokens from referenced readable Lua files', function() @@ -197,10 +252,9 @@ describe('opencode.ui.symbol_snapshot', function() { id = 3, node = fake_node('ignored', 0, 0) }, }) - local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) - local targets = symbol_snapshot.targets_for_token(snapshot, 'foo') + local cycle = symbol_snapshot.new_cycle() + local targets = symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' }) - assert.is_true(symbol_snapshot.has_token(snapshot, 'foo')) assert.equal(1, #targets) assert.equal('/test/project/src/main.lua', targets[1].path) assert.equal(1, targets[1].line) @@ -216,26 +270,27 @@ describe('opencode.ui.symbol_snapshot', function() { id = 1, node = fake_node('bar', 0, 15) }, }) - local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local cycle = symbol_snapshot.new_cycle() - assert.is_true(symbol_snapshot.has_token(snapshot, 'foo')) - assert.is_false(symbol_snapshot.has_token(snapshot, 'bar')) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'bar', { '/test/project/src/main.lua' })) end) it('reflects file changes on each collect call', function() set_file('/test/project/src/main.lua', { 'local function foo() end' }, { { id = 1, node = fake_node('foo', 0, 15) }, }) - local first = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local first = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(first, 'foo', { '/test/project/src/main.lua' })) set_file('/test/project/src/main.lua', { '', '', 'local function bar() end' }, { { id = 1, node = fake_node('bar', 2, 15) }, }) - local second = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local second = symbol_snapshot.new_cycle() - assert.is_true(symbol_snapshot.has_token(first, 'foo')) - assert.is_false(symbol_snapshot.has_token(second, 'foo')) - local targets = symbol_snapshot.targets_for_token(second, 'bar') + assert.equal(1, #symbol_snapshot.targets_for_token(first, 'foo', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(second, 'foo', { '/test/project/src/main.lua' })) + local targets = symbol_snapshot.targets_for_token(second, 'bar', { '/test/project/src/main.lua' }) assert.equal(1, #targets) assert.equal(3, targets[1].line) end) @@ -252,10 +307,10 @@ describe('opencode.ui.symbol_snapshot', function() { id = 1, node = fake_node('buffer_name', 0, 15) }, } - local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local cycle = symbol_snapshot.new_cycle() - assert.is_true(symbol_snapshot.has_token(snapshot, 'buffer_name')) - assert.is_false(symbol_snapshot.has_token(snapshot, 'disk_name')) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'buffer_name', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'disk_name', { '/test/project/src/main.lua' })) end) it('filters empty, short, numeric, and whitespace definition tokens', function() @@ -267,12 +322,12 @@ describe('opencode.ui.symbol_snapshot', function() { id = 1, node = fake_node('ok', 0, 0) }, }) - local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local cycle = symbol_snapshot.new_cycle() - assert.is_false(symbol_snapshot.has_token(snapshot, 'x')) - assert.is_false(symbol_snapshot.has_token(snapshot, '123')) - assert.is_false(symbol_snapshot.has_token(snapshot, 'two words')) - assert.is_true(symbol_snapshot.has_token(snapshot, 'ok')) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'x', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, '123', { '/test/project/src/main.lua' })) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'two words', { '/test/project/src/main.lua' })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'ok', { '/test/project/src/main.lua' })) end) it('skips Lua associated owner captures', function() @@ -281,10 +336,10 @@ describe('opencode.ui.symbol_snapshot', function() { id = 1, node = fake_node('_call', 0, 27) }, }) - local snapshot = symbol_snapshot.collect({ { file_path = 'src/client.lua' } }) + local cycle = symbol_snapshot.new_cycle() - assert.is_false(symbol_snapshot.has_token(snapshot, 'OpencodeApiClient')) - assert.is_true(symbol_snapshot.has_token(snapshot, '_call')) + assert.equal(0, #symbol_snapshot.targets_for_token(cycle, 'OpencodeApiClient', { '/test/project/src/client.lua' })) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, '_call', { '/test/project/src/client.lua' })) end) it('silently skips parser and query failures', function() @@ -293,13 +348,13 @@ describe('opencode.ui.symbol_snapshot', function() }) parser_available = false - local no_parser = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local no_parser = symbol_snapshot.new_cycle() + assert.equal(0, #symbol_snapshot.targets_for_token(no_parser, 'foo', { '/test/project/src/main.lua' })) parser_available = true query_available = false - local no_query = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) + local no_query = symbol_snapshot.new_cycle() - assert.is_false(symbol_snapshot.has_token(no_parser, 'foo')) - assert.is_false(symbol_snapshot.has_token(no_query, 'foo')) + assert.equal(0, #symbol_snapshot.targets_for_token(no_query, 'foo', { '/test/project/src/main.lua' })) assert.equal(0, #notify_calls) end) @@ -308,11 +363,11 @@ describe('opencode.ui.symbol_snapshot', function() { id = 1, node = fake_node('foo', 0, 15) }, }) - local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) - local targets = symbol_snapshot.targets_for_token(snapshot, 'foo') + local cycle = symbol_snapshot.new_cycle() + local targets = symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' }) table.remove(targets, 1) - assert.equal(1, #symbol_snapshot.targets_for_token(snapshot, 'foo')) + assert.equal(1, #symbol_snapshot.targets_for_token(cycle, 'foo', { '/test/project/src/main.lua' })) end) it('keeps token variants stable, deduplicated, and whole-token first', function() @@ -333,14 +388,14 @@ describe('opencode.ui.symbol_snapshot', function() { id = 1, node = fake_node('jump_to_file', 0, 15) }, }) - local snapshot = symbol_snapshot.collect({ { file_path = 'src/main.lua' } }) - local exact_targets = symbol_snapshot.targets_for_token(snapshot, 'jump_to_file') - local qualified_targets = symbol_snapshot.targets_for_token(snapshot, 'M.actions.jump_to_file') + local cycle = symbol_snapshot.new_cycle() + local exact_targets = symbol_snapshot.targets_for_token(cycle, 'jump_to_file', { '/test/project/src/main.lua' }) + local qualified_targets = + symbol_snapshot.targets_for_token(cycle, 'M.actions.jump_to_file', { '/test/project/src/main.lua' }) - assert.is_true(symbol_snapshot.has_token(snapshot, 'jump_to_file')) assert.equal(1, #exact_targets) assert.equal('jump_to_file', exact_targets[1].token) - assert.is_false(symbol_snapshot.has_token(snapshot, 'M.actions.jump_to_file')) - assert.same({}, qualified_targets) + assert.equal(1, #qualified_targets) + assert.equal('jump_to_file', qualified_targets[1].token) end) end) From 8ff8bcccf73551157103e99a945c1a3ef18f47aa Mon Sep 17 00:00:00 2001 From: jensenojs Date: Thu, 2 Jul 2026 17:11:08 +0800 Subject: [PATCH 4/7] Stabilize timer restart test --- tests/unit/timer_spec.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/unit/timer_spec.lua b/tests/unit/timer_spec.lua index 0873242c..f8654565 100644 --- a/tests/unit/timer_spec.lua +++ b/tests/unit/timer_spec.lua @@ -335,18 +335,18 @@ describe('Timer', function() -- Start, wait for ticks, then stop timer:start() - vim.wait(30, function() + assert.is_true(vim.wait(1000, function() return tick_count >= 2 - end) + end)) timer:stop() local count_after_stop = tick_count -- Restart and verify it works again timer:start() - vim.wait(30, function() + assert.is_true(vim.wait(1000, function() return tick_count > count_after_stop + 1 - end) + end)) assert.is_true(tick_count > count_after_stop + 1) assert.is_true(timer:is_running()) From 835f68c0cc015842b4be08fb242b998a739d7627 Mon Sep 17 00:00:00 2001 From: jensenojs Date: Thu, 2 Jul 2026 19:01:05 +0800 Subject: [PATCH 5/7] Remove AGENTS prompt additions --- AGENTS.md | 102 -------------------------------------- lua/opencode/ui/AGENTS.md | 63 ----------------------- 2 files changed, 165 deletions(-) delete mode 100644 lua/opencode/ui/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 4bda72e3..76c0eb57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,105 +21,3 @@ Use `scripts/dependency-topology/scan_topology.py` to inspect and track architec - Pass `--snapshot ` for historical snapshots - Pass `--json` when feeding outputs into scripts or agents - Keep architecture cleanup discussions anchored on scanner output instead of ad-hoc grep chains - -## Runtime Performance Profiling - -Use this section when there is a runtime performance problem or a credible performance report: slow render, delayed keypress, streaming lag, startup slowdown, a profiler screenshot, or a benchmark/test showing a regression. Before changing code for that problem, capture evidence and reduce it to a cost model. Pick the profiling method by what is available; do not require a specific plugin. - -### Capture options - -Use the first option that fits the machine and the symptom. - -#### Instrumentation profiler - -Use this when a profiler plugin is already available. It records function call trees with time and count. - -Example with `folke/snacks.nvim`, if it is installed: - -```vim -:lua Snacks.profiler.start() -" reproduce the slow action once -:lua Snacks.profiler.stop({ pick = true }) -``` - -Read it as a call tree. Parent time includes child time. `count` is useful for spotting repeated work. - -#### LuaJIT sampling profiler - -Use this when no profiler plugin is available. Neovim normally exposes LuaJIT's profiler as `jit.p`. - -```vim -:lua require('jit.p').start('fl', '/tmp/nvim-jit-profile.log') -" reproduce the slow action once -:lua require('jit.p').stop() -``` - -Open `/tmp/nvim-jit-profile.log`. Treat it like a sampled CPU profile: it shows where Lua spent CPU time by stack/location, but it does not give exact call counts. If the issue is repeated work, pair it with a counter or scoped timer. - -#### Scoped wall-time timer - -Use this when the question is “which lifecycle boundary blocks the user?” or when sampling does not show wall-clock delay. Add temporary instrumentation around suspected boundaries only while investigating. - -```lua -local uv = vim.uv or vim.loop -local start = uv.hrtime() --- code under investigation -local elapsed_ms = (uv.hrtime() - start) / 1e6 -vim.notify(string.format('opencode profile: %.2fms', elapsed_ms)) -``` - -For repeated calls, accumulate count and total time: - -```lua -_G.opencode_perf = _G.opencode_perf or {} -local p = _G.opencode_perf[name] or { count = 0, total_ms = 0 } -p.count = p.count + 1 -p.total_ms = p.total_ms + elapsed_ms -_G.opencode_perf[name] = p -``` - -Remove temporary instrumentation before committing unless the user explicitly asks for a diagnostic hook. - -#### Startup profile - -Use this only for startup or plugin-load regressions: - -```bash -nvim --startuptime /tmp/nvim-startuptime.log -``` - -This is not a runtime action profiler. Do not use it to explain a slow keypress, render flush, or streaming callback. - -### Interpret the profile - -Start from the user-visible trigger, then walk down the stack. - -Record: - -```text -trigger: -blocking point: -hot stack: callee -> hotspot> -count: -cost: -repeated unit: -invariant data: -``` - -Rules for reading evidence: - -- In instrumentation traces, parent time includes child time. If parent and child times are almost equal, optimize the child or the child's call frequency. -- In sampling traces, sample share is not exact wall time and does not prove call count. Use it to find the hot stack, then verify count with instrumentation or counters. -- A 400 ms function called 10 times is a repeated-work problem. A 4 s function called once is a single expensive operation. -- Do not optimize tiny high-count helpers unless their caller stack explains the user-visible delay. - -### Fix criteria - -A valid fix must change one measured fact: - -- remove expensive work from the blocking path; -- move invariant work to the smallest valid lifecycle boundary; -- defer work to an explicit user action; -- reduce repeated calls and prove the new call count with a test. - -Do not add a cache until its invalidation boundary is named. Acceptable boundaries are concrete lifecycle points such as one render flush, one full session render, one keypress, one state change subscription, or one buffer change. Add a regression test that fails on the old call count. diff --git a/lua/opencode/ui/AGENTS.md b/lua/opencode/ui/AGENTS.md deleted file mode 100644 index 0e4b7166..00000000 --- a/lua/opencode/ui/AGENTS.md +++ /dev/null @@ -1,63 +0,0 @@ -# AGENTS.md (ui) - -This directory owns the rendered conversation UI and the interactive targets drawn on top of assistant text. - -## Reference target model - -The stable chain is: - -```text -assistant text - -> reference_parser: positioned mention spans - -> reference_facts: current-session refs + current executable file list - -> formatter/render: screen-coordinate file and symbol targets - -> navigation: execute the current RenderState target only -``` - -`reference_parser` only identifies text spans. It does not prove that a file exists. It must keep separate non-overlapping mentions even when they point to the same path. Path-level dedupe belongs only to picker-style file lists. - -`reference_facts` is the maintained projection from current session messages. It owns two facts: current refs from assistant text and tool file-path facts, and the current executable file list derived from those refs. A file is executable when the referenced path currently exists on disk. This file list is the authority for rendering file affordances. - -`formatter` must not parse assistant text or scan session messages. It consumes `context.current_refs` and `context.current_files`. A mention becomes an icon, highlight, and `RenderState` file target only when its path is present in `current_files`. A missing file mention stays ordinary text. - -Symbol targets are bounded by the same file list. During a render cycle, `symbol_snapshot.new_cycle()` may reuse per-file Tree-sitter work inside that cycle. Symbol truth must not become long-lived UI state. - -`navigation` consumes `RenderState` targets. It must not rediscover targets from the output buffer text. Keypress executes the target that render already produced; it is not a target lifecycle or refresh boundary. - -Assistant message updates maintain `reference_facts` incrementally. New reference mentions extend the current refs and rebuild the executable file list before the affected rendered text parts are formatted. - -`file.edited`, `file.watcher.updated`, and local buffer file lifecycle events are render invalidation boundaries. Local writes, buffer renames, buffer unloads, shell-change notifications, server file edits, and watcher add/change/unlink events can change executable files and symbol truth without changing assistant text. They refresh the reference file list and dirty currently rendered assistant text parts. The next render recreates or removes affordances through the same path: current refs, current file list, current Tree-sitter snapshot, formatter output. - -This invalidation is limited to parts already in `RenderState`. Lazy-rendered history that is not in the output buffer waits for its normal render path. In normal edits the reference file list often stays the same; only symbol truth changes, so the next render reuses the same reference files and a fresh per-render Tree-sitter cycle. - -## Expected failure diagnosis - -If a visible path does not jump, inspect in this order: - -```text -cursor position - -> renderer.get_target_at_position(line, col) - -> reference_facts.current_files() - -> formatter context for that render - -> navigation result -``` - -If `reference_facts.current_refs()` contains a mention but `renderer.get_target_at_position()` is nil, the problem is render projection or file-list membership. - -If `renderer.get_target_at_position()` returns a target but jump fails, the problem is keypress-time execution or a missing edit invalidation event. Keypress must not patch the rendered state; fix the save/edit invalidation path. - -If a nonexistent file has an icon or highlight, the bug is in render projection. Do not add cwd/root fallback code in `formatter`; fix the file list or the mention source. - -## Editing rule - -Prefer removing duplicate derivations over adding recovery paths. The UI should have one path from facts to rendered targets, and one path from rendered targets to execution. - -Do not add a second resolver layer, compatibility shim, screen-text scanner, or root fallback to hide a broken file list. - -## Regression commands - -- `./run_tests.sh -t tests/unit/reference_facts_spec.lua` -- `./run_tests.sh -t tests/unit/formatter_spec.lua` -- `./run_tests.sh -t tests/unit/navigation_spec.lua` -- `./run_tests.sh -t tests/unit/renderer_targets_spec.lua` -- `./run_tests.sh -t tests/replay/renderer_spec.lua` From 51878828c4f640fa5a64894ac44a30a34f35745f Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 2 Jul 2026 09:24:00 -0400 Subject: [PATCH 6/7] fix: pass through left mouse click on non-message areas in output window --- lua/opencode/ui/message_actions.lua | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lua/opencode/ui/message_actions.lua b/lua/opencode/ui/message_actions.lua index afb46e9d..453d249e 100644 --- a/lua/opencode/ui/message_actions.lua +++ b/lua/opencode/ui/message_actions.lua @@ -231,17 +231,24 @@ function M.open_at_cursor() open_for_message(message, output_buf) end +local function pass_through_left_mouse() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, true, true), 'n', false) +end + function M.open_from_mouse() local output_win, output_buf = output_window() if not output_win or not output_buf then + pass_through_left_mouse() return end local mouse = vim.fn.getmousepos() if not mouse or mouse.winid ~= output_win then + pass_through_left_mouse() return end if not vim.api.nvim_win_is_valid(mouse.winid) or vim.api.nvim_win_get_buf(mouse.winid) ~= output_buf then + pass_through_left_mouse() return end if not mouse.line or mouse.line <= 0 then From a667e47253ff7db9873b6cf6fe6d7a1c9fbc09c6 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Mon, 6 Jul 2026 07:38:31 -0400 Subject: [PATCH 7/7] feat(output): add file navigation targets to tool output --- lua/opencode/ui/formatter/tools/file.lua | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lua/opencode/ui/formatter/tools/file.lua b/lua/opencode/ui/formatter/tools/file.lua index b1be12d8..b61bfc4e 100644 --- a/lua/opencode/ui/formatter/tools/file.lua +++ b/lua/opencode/ui/formatter/tools/file.lua @@ -62,8 +62,22 @@ function M.format(output, part) local utils = require('opencode.ui.formatter.utils') local config = require('opencode.config') - local icons = require('opencode.ui.icons') - utils.format_action(output, icons.get(tool_type), tool_type, file_name, utils.get_duration_text(part)) + local icon_text = icons.get(tool_type) + utils.format_action(output, icon_text, tool_type, file_name, utils.get_duration_text(part)) + + if file_name ~= '' and input.filePath then + local action_line = output:get_line_count() + local line_content = output:get_line(action_line) + output:add_target({ + kind = 'file', + path = input.filePath, + range = { + line = action_line, + start_col = 0, + end_col = line_content and #line_content or 0, + }, + }) + end local start_line = output:get_line_count() + 1 if not (config.ui.output.tools.show_output or config.ui.output.tools.use_folds) then