Skip to content

Commit 69f5170

Browse files
committed
Refine reference target lifecycle
1 parent 3be36d8 commit 69f5170

50 files changed

Lines changed: 3970 additions & 2741 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,105 @@ Use `scripts/dependency-topology/scan_topology.py` to inspect and track architec
2121
- Pass `--snapshot <git-ref>` for historical snapshots
2222
- Pass `--json` when feeding outputs into scripts or agents
2323
- Keep architecture cleanup discussions anchored on scanner output instead of ad-hoc grep chains
24+
25+
## Runtime Performance Profiling
26+
27+
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.
28+
29+
### Capture options
30+
31+
Use the first option that fits the machine and the symptom.
32+
33+
#### Instrumentation profiler
34+
35+
Use this when a profiler plugin is already available. It records function call trees with time and count.
36+
37+
Example with `folke/snacks.nvim`, if it is installed:
38+
39+
```vim
40+
:lua Snacks.profiler.start()
41+
" reproduce the slow action once
42+
:lua Snacks.profiler.stop({ pick = true })
43+
```
44+
45+
Read it as a call tree. Parent time includes child time. `count` is useful for spotting repeated work.
46+
47+
#### LuaJIT sampling profiler
48+
49+
Use this when no profiler plugin is available. Neovim normally exposes LuaJIT's profiler as `jit.p`.
50+
51+
```vim
52+
:lua require('jit.p').start('fl', '/tmp/nvim-jit-profile.log')
53+
" reproduce the slow action once
54+
:lua require('jit.p').stop()
55+
```
56+
57+
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.
58+
59+
#### Scoped wall-time timer
60+
61+
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.
62+
63+
```lua
64+
local uv = vim.uv or vim.loop
65+
local start = uv.hrtime()
66+
-- code under investigation
67+
local elapsed_ms = (uv.hrtime() - start) / 1e6
68+
vim.notify(string.format('opencode profile: <name> %.2fms', elapsed_ms))
69+
```
70+
71+
For repeated calls, accumulate count and total time:
72+
73+
```lua
74+
_G.opencode_perf = _G.opencode_perf or {}
75+
local p = _G.opencode_perf[name] or { count = 0, total_ms = 0 }
76+
p.count = p.count + 1
77+
p.total_ms = p.total_ms + elapsed_ms
78+
_G.opencode_perf[name] = p
79+
```
80+
81+
Remove temporary instrumentation before committing unless the user explicitly asks for a diagnostic hook.
82+
83+
#### Startup profile
84+
85+
Use this only for startup or plugin-load regressions:
86+
87+
```bash
88+
nvim --startuptime /tmp/nvim-startuptime.log
89+
```
90+
91+
This is not a runtime action profiler. Do not use it to explain a slow keypress, render flush, or streaming callback.
92+
93+
### Interpret the profile
94+
95+
Start from the user-visible trigger, then walk down the stack.
96+
97+
Record:
98+
99+
```text
100+
trigger: <user action that starts the slow path>
101+
blocking point: <render before display | keypress | streaming callback | startup>
102+
hot stack: <caller -> callee -> hotspot>
103+
count: <hotspot calls per trigger, or "sampled" if using jit.p>
104+
cost: <hotspot total time and per-call time if available>
105+
repeated unit: <message | part | line | file | buffer | session>
106+
invariant data: <inputs that are unchanged across repeated calls>
107+
```
108+
109+
Rules for reading evidence:
110+
111+
- 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.
112+
- 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.
113+
- A 400 ms function called 10 times is a repeated-work problem. A 4 s function called once is a single expensive operation.
114+
- Do not optimize tiny high-count helpers unless their caller stack explains the user-visible delay.
115+
116+
### Fix criteria
117+
118+
A valid fix must change one measured fact:
119+
120+
- remove expensive work from the blocking path;
121+
- move invariant work to the smallest valid lifecycle boundary;
122+
- defer work to an explicit user action;
123+
- reduce repeated calls and prove the new call count with a test.
124+
125+
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.

lua/opencode/init.lua

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ function M.setup(opts)
5959
require('opencode.event_manager').setup()
6060
require('opencode.context').setup()
6161
require('opencode.ui.context_bar').setup()
62-
require('opencode.ui.reference_picker').setup()
6362
end
6463

6564
return M

lua/opencode/types.lua

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,48 @@
545545
---@field display_line number Line number to display the action
546546
---@field range? { from: number, to: number } Optional range for the action
547547

548+
---@class CodeReferenceTextRange
549+
---@field start_offset integer Raw part text offset, 1-based inclusive
550+
---@field end_offset integer Raw part text offset, 1-based inclusive
551+
552+
---@class CodeReference
553+
---@field session_id string
554+
---@field message_id string
555+
---@field part_id string
556+
---@field path string
557+
---@field line? integer
558+
---@field col? integer
559+
---@field source_kind 'assistant_text'|'tool_file_path'
560+
---@field raw_range? CodeReferenceTextRange Required for assistant_text references
561+
---@field order integer Smaller values appear earlier in the session message/part/text order.
562+
563+
---@class SymbolSnapshotCycle
564+
565+
---@class FormatterContext
566+
---@field interactive boolean
567+
---@field get_child_parts? fun(session_id: string): OpencodeMessagePart[]?
568+
---@field current_refs? CodeReference[]
569+
---@field current_files? string[]
570+
---@field symbol_cycle? SymbolSnapshotCycle
571+
572+
---@class OutputTargetRange
573+
---@field line integer Output-local line, 1-based
574+
---@field start_col integer Output-local column, 0-based inclusive
575+
---@field end_col integer Output-local column, 0-based exclusive
576+
577+
---@class OutputTarget
578+
---@field kind 'file'|'diff'|'symbol'
579+
---@field range OutputTargetRange
580+
---@field path? string
581+
---@field line? integer
582+
---@field col? integer
583+
---@field token? string
584+
---@field candidate_files? string[]
585+
586+
---@class RenderedTarget: OutputTarget
587+
---@field part_id string
588+
---@field message_id string
589+
548590
---@alias OutputExtmarkType vim.api.keyset.set_extmark & {start_col:0}
549591
---@alias OutputExtmark OutputExtmarkType|fun():OutputExtmarkType
550592

lua/opencode/ui/AGENTS.md

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# AGENTS.md (ui)
2+
3+
This directory owns the rendered conversation UI and the interactive targets drawn on top of assistant text.
4+
5+
## Reference target model
6+
7+
The stable chain is:
8+
9+
```text
10+
assistant text
11+
-> reference_parser: positioned mention spans
12+
-> reference_facts: current-session refs + current executable file list
13+
-> formatter/render: screen-coordinate file and symbol targets
14+
-> navigation: execute the current RenderState target only
15+
```
16+
17+
`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.
18+
19+
`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.
20+
21+
`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.
22+
23+
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.
24+
25+
`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.
26+
27+
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.
28+
29+
`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.
30+
31+
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.
32+
33+
## Expected failure diagnosis
34+
35+
If a visible path does not jump, inspect in this order:
36+
37+
```text
38+
cursor position
39+
-> renderer.get_target_at_position(line, col)
40+
-> reference_facts.current_files()
41+
-> formatter context for that render
42+
-> navigation result
43+
```
44+
45+
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.
46+
47+
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.
48+
49+
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.
50+
51+
## Editing rule
52+
53+
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.
54+
55+
Do not add a second resolver layer, compatibility shim, screen-text scanner, or root fallback to hide a broken file list.
56+
57+
## Regression commands
58+
59+
- `./run_tests.sh -t tests/unit/reference_facts_spec.lua`
60+
- `./run_tests.sh -t tests/unit/formatter_spec.lua`
61+
- `./run_tests.sh -t tests/unit/navigation_spec.lua`
62+
- `./run_tests.sh -t tests/unit/renderer_targets_spec.lua`
63+
- `./run_tests.sh -t tests/replay/renderer_spec.lua`

lua/opencode/ui/autocmds.lua

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ function M.setup_autocmds(windows)
3939
end,
4040
})
4141

42+
vim.api.nvim_create_autocmd({ 'BufWritePost', 'BufFilePost', 'BufDelete', 'BufWipeout', 'FileChangedShellPost' }, {
43+
group = group,
44+
pattern = '*',
45+
callback = function(args)
46+
if args.file == '' then
47+
return
48+
end
49+
require('opencode.ui.renderer.events').invalidate_reference_targets_for_file_change()
50+
end,
51+
})
52+
4253
vim.api.nvim_create_autocmd('WinEnter', {
4354
group = group,
4455
pattern = '*',

lua/opencode/ui/event_scope.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,9 @@ local policies = {
8787
['file.edited'] = function()
8888
return true
8989
end,
90+
['file.watcher.updated'] = function()
91+
return true
92+
end,
9093
['custom.restore_point.created'] = function()
9194
return true
9295
end,

0 commit comments

Comments
 (0)