Skip to content

Commit 33bb81d

Browse files
committed
refactor(commands): unify parsed-intent execution axis
- Converge execution flow to parse/build -> bind_action_context -> dispatch.execute - Keep parse pure by returning ParsedIntent without execute injection - Route API/keymap/slash through build_parsed_intent + execute_parsed_intent - Add runtime dispatch hook register/unregister with command filters - Fix keymap table-arg mutation leak and normalize invalid permission subcommands - Update type/event annotations and add axis/mapping regression tests
1 parent 356a7d0 commit 33bb81d

19 files changed

Lines changed: 1163 additions & 371 deletions

lua/opencode/api.lua

Lines changed: 129 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
local dispatch = require('opencode.commands.dispatch')
1+
local commands = require('opencode.commands')
22
local window = require('opencode.commands.handlers.window').actions
33
local session = require('opencode.commands.handlers.session').actions
44
local diff = require('opencode.commands.handlers.diff').actions
@@ -7,119 +7,142 @@ local workflow = require('opencode.commands.handlers.workflow').actions
77
local permission = require('opencode.commands.handlers.permission').actions
88
local agent = require('opencode.commands.handlers.agent').actions
99

10-
-- Routes an action through the dispatch pipeline so lifecycle hooks fire.
11-
-- TODO: hooks (on_command_before/after/error/finally) are not yet in config
12-
-- defaults — activate them when the event unification work lands.
13-
local function via_dispatch(action_fn, ...)
14-
local args = { ... }
15-
return dispatch.dispatch_intent({
16-
ok = true,
17-
intent = { execute = function() return action_fn(unpack(args)) end, args = {}, range = nil },
18-
}).result
10+
-- Route API actions through the same command execution axis.
11+
---@param hook_key? string
12+
local function dispatch_action(name, action_fn, hook_key, ...)
13+
local parsed = commands.build_parsed_intent(name, vim.deepcopy({ ... }))
14+
if hook_key and parsed and parsed.ok and parsed.intent then
15+
parsed.intent.hook_key = hook_key
16+
end
17+
18+
return commands.execute_parsed_intent(parsed, function(resolved_args)
19+
return action_fn(unpack(resolved_args or {}))
20+
end)
1921
end
2022

2123
---@type OpencodeCommandApi
2224
local M = {}
2325

24-
-- All dispatch-wrapped actions. Generated into M via the loop below.
25-
-- Read-only queries (get_window_state, with_header, current_model) are defined after.
26-
-- stylua: ignore
27-
local actions = {
28-
-- window
29-
swap_position = window.swap_position,
30-
toggle_zoom = window.toggle_zoom,
31-
toggle_input = window.toggle_input,
32-
open_input = window.open_input,
33-
open_output = window.open_output,
34-
close = window.close,
35-
hide = window.hide,
36-
toggle_pane = window.toggle_pane,
37-
focus_input = window.focus_input,
38-
cancel = window.cancel,
39-
toggle_focus = window.toggle_focus, -- (new_sess)
40-
toggle = window.toggle, -- (new_sess)
41-
-- session
42-
open_input_new_session = session.open_input_new_session,
43-
select_child_session = session.select_child_session,
44-
share = session.share,
45-
unshare = session.unshare,
46-
initialize = session.initialize,
47-
timeline = session.timeline,
48-
redo = session.redo,
49-
select_session = session.select_session, -- (parent_id)
50-
compact_session = session.compact_session, -- (s)
51-
open_input_new_session_with_title = session.open_input_new_session_with_title, -- (title)
52-
rename_session = session.rename_session, -- (s, title)
53-
undo = session.undo, -- (msg_id)
54-
fork_session = session.fork_session, -- (msg_id)
55-
-- diff
56-
diff_next = diff.diff_next,
57-
diff_prev = diff.diff_prev,
58-
diff_close = diff.diff_close,
59-
diff_revert_all_last_prompt = diff.diff_revert_all_last_prompt,
60-
diff_revert_this_last_prompt = diff.diff_revert_this_last_prompt,
61-
set_review_breakpoint = diff.set_review_breakpoint,
62-
diff_open = diff.diff_open, -- (from, to)
63-
diff_revert_all = diff.diff_revert_all, -- (snap)
64-
diff_revert_selected_file = diff.diff_revert_selected_file, -- (s, t)
65-
diff_restore_snapshot_file = diff.diff_restore_snapshot_file, -- (id)
66-
diff_restore_snapshot_all = diff.diff_restore_snapshot_all, -- (id)
67-
diff_revert_this = diff.diff_revert_this, -- (snap)
68-
-- workflow
69-
paste_image = workflow.paste_image,
70-
select_history = workflow.select_history,
71-
prev_history = workflow.prev_history,
72-
next_history = workflow.next_history,
73-
prev_prompt_history = workflow.prev_prompt_history,
74-
next_prompt_history = workflow.next_prompt_history,
75-
next_message = workflow.next_message,
76-
prev_message = workflow.prev_message,
77-
mention_file = workflow.mention_file,
78-
mention = workflow.mention,
79-
context_items = workflow.context_items,
80-
slash_commands = workflow.slash_commands,
81-
references = workflow.references,
82-
debug_output = workflow.debug_output,
83-
debug_message = workflow.debug_message,
84-
debug_session = workflow.debug_session,
85-
toggle_tool_output = workflow.toggle_tool_output,
86-
toggle_reasoning_output = workflow.toggle_reasoning_output,
87-
submit_input_prompt = workflow.submit_input_prompt,
88-
run = workflow.run, -- (prompt, opts)
89-
run_new_session = workflow.run_new_session, -- (prompt, opts)
90-
quick_chat = workflow.quick_chat, -- (msg, range)
91-
run_user_command = workflow.run_user_command, -- (name, args)
92-
review = workflow.review, -- (args, range)
93-
add_visual_selection = workflow.add_visual_selection, -- (opts, range)
94-
add_visual_selection_inline = workflow.add_visual_selection_inline, -- (o, r)
95-
-- surface
96-
help = surface.help,
97-
mcp = surface.mcp,
98-
commands_list = surface.commands_list,
99-
-- permission
100-
question_answer = permission.question_answer,
101-
question_other = permission.question_other,
102-
respond_to_permission = permission.respond_to_permission, -- (answer, perm)
103-
permission_accept = permission.permission_accept, -- (perm)
104-
permission_accept_all = permission.permission_accept_all, -- (perm)
105-
permission_deny = permission.permission_deny, -- (perm)
106-
-- agent
107-
configure_provider = agent.configure_provider,
108-
configure_variant = agent.configure_variant,
109-
cycle_variant = agent.cycle_variant,
110-
agent_plan = agent.agent_plan,
111-
agent_build = agent.agent_build,
112-
select_agent = agent.select_agent,
113-
switch_mode = agent.switch_mode,
26+
local action_groups = {
27+
window = {
28+
swap_position = window.swap_position,
29+
toggle_zoom = window.toggle_zoom,
30+
toggle_input = window.toggle_input,
31+
open_input = window.open_input,
32+
open_output = window.open_output,
33+
close = window.close,
34+
hide = window.hide,
35+
toggle_pane = window.toggle_pane,
36+
focus_input = window.focus_input,
37+
cancel = window.cancel,
38+
toggle_focus = window.toggle_focus,
39+
toggle = window.toggle,
40+
},
41+
42+
session = {
43+
open_input_new_session = session.open_input_new_session,
44+
select_child_session = session.select_child_session,
45+
share = session.share,
46+
unshare = session.unshare,
47+
initialize = session.initialize,
48+
timeline = session.timeline,
49+
redo = session.redo,
50+
select_session = session.select_session,
51+
compact_session = session.compact_session,
52+
open_input_new_session_with_title = session.open_input_new_session_with_title,
53+
rename_session = session.rename_session,
54+
undo = session.undo,
55+
fork_session = session.fork_session,
56+
},
57+
58+
diff = {
59+
diff_next = diff.diff_next,
60+
diff_prev = diff.diff_prev,
61+
diff_close = diff.diff_close,
62+
diff_revert_all_last_prompt = diff.diff_revert_all_last_prompt,
63+
diff_revert_this_last_prompt = diff.diff_revert_this_last_prompt,
64+
set_review_breakpoint = diff.set_review_breakpoint,
65+
diff_open = diff.diff_open,
66+
diff_revert_all = diff.diff_revert_all,
67+
diff_revert_selected_file = diff.diff_revert_selected_file,
68+
diff_restore_snapshot_file = diff.diff_restore_snapshot_file,
69+
diff_restore_snapshot_all = diff.diff_restore_snapshot_all,
70+
diff_revert_this = diff.diff_revert_this,
71+
},
72+
73+
workflow = {
74+
paste_image = workflow.paste_image,
75+
select_history = workflow.select_history,
76+
prev_history = workflow.prev_history,
77+
next_history = workflow.next_history,
78+
prev_prompt_history = workflow.prev_prompt_history,
79+
next_prompt_history = workflow.next_prompt_history,
80+
next_message = workflow.next_message,
81+
prev_message = workflow.prev_message,
82+
mention_file = workflow.mention_file,
83+
mention = workflow.mention,
84+
context_items = workflow.context_items,
85+
slash_commands = workflow.slash_commands,
86+
references = workflow.references,
87+
debug_output = workflow.debug_output,
88+
debug_message = workflow.debug_message,
89+
debug_session = workflow.debug_session,
90+
toggle_tool_output = workflow.toggle_tool_output,
91+
toggle_reasoning_output = workflow.toggle_reasoning_output,
92+
submit_input_prompt = workflow.submit_input_prompt,
93+
run = workflow.run,
94+
run_new_session = workflow.run_new_session,
95+
quick_chat = workflow.quick_chat,
96+
run_user_command = workflow.run_user_command,
97+
review = workflow.review,
98+
add_visual_selection = workflow.add_visual_selection,
99+
add_visual_selection_inline = workflow.add_visual_selection_inline,
100+
},
101+
102+
surface = {
103+
help = surface.help,
104+
mcp = surface.mcp,
105+
commands_list = surface.commands_list,
106+
},
107+
108+
permission = {
109+
question_answer = permission.question_answer,
110+
question_other = permission.question_other,
111+
respond_to_permission = permission.respond_to_permission,
112+
permission_accept = permission.permission_accept,
113+
permission_accept_all = permission.permission_accept_all,
114+
permission_deny = permission.permission_deny,
115+
},
116+
117+
agent = {
118+
configure_provider = agent.configure_provider,
119+
configure_variant = agent.configure_variant,
120+
cycle_variant = agent.cycle_variant,
121+
agent_plan = agent.agent_plan,
122+
agent_build = agent.agent_build,
123+
select_agent = agent.select_agent,
124+
switch_mode = agent.switch_mode,
125+
},
126+
127+
query = {
128+
get_window_state = window.get_window_state,
129+
current_model = agent.current_model,
130+
with_header = surface.with_header,
131+
},
114132
}
115133

116-
for name, fn in pairs(actions) do
117-
M[name] = function(...) return via_dispatch(fn, ...) end
134+
---@param group_name string
135+
---@param exports table<string, function>
136+
local function register_exports(group_name, exports)
137+
for name, fn in pairs(exports) do
138+
M[name] = function(...)
139+
return dispatch_action(name, fn, group_name, ...)
140+
end
141+
end
118142
end
119143

120-
-- Read-only queries: bypass dispatch, no side effects
121-
function M.get_window_state() return window.get_window_state() end
122-
function M.current_model() return agent.current_model() end
123-
function M.with_header(lines, show_welcome) return surface.with_header(lines, show_welcome) end
144+
for group_name, exports in pairs(action_groups) do
145+
register_exports(group_name, exports)
146+
end
124147

125148
return M

lua/opencode/commands/AGENTS.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# AGENTS.md (commands)
2+
3+
This directory defines the command execution pipeline.
4+
5+
## Scope
6+
7+
- Parse command input into structured intent (`parse.lua`)
8+
- Bind intent to executable action (`init.lua`)
9+
- Execute lifecycle (`dispatch.lua`)
10+
- Wire slash/keymap/API into the same axis
11+
12+
## Hard Invariants
13+
14+
- Single execution entry: `dispatch.execute(ctx)`
15+
- Single bind point: `commands.bind_action_context(...)`
16+
- `parse.lua` does not bind execute functions
17+
- No `dispatch.run`, no `route.execute`, no fake parse wrapper
18+
19+
## Hook Model
20+
21+
- Stage hooks: `before`, `after`, `error`, `finally`
22+
- Global hook: no command filter (`command = '*'` or omitted)
23+
- Command-scoped hook: `command = 'run'` or `{'run','review'}`
24+
- Keep hook handlers side-effect aware and idempotent
25+
26+
## Expected Execution Shape
27+
28+
```text
29+
entry (:Opencode | keymap | API | slash)
30+
-> parse/build intent
31+
-> bind_action_context (single bind point)
32+
-> dispatch.execute (single execute point)
33+
-> hooks(before/after/error/finally)
34+
```
35+
36+
## Editing Rules
37+
38+
- Prefer deleting duplicated glue code over adding adapters
39+
- Keep error normalization in `dispatch.lua`
40+
- Keep notify behavior unchanged unless explicitly requested
41+
- Do not split semantics by entry (`:Opencode` / keymap / API / slash)
42+
43+
## Allow / Disallow Examples
44+
45+
- Allowed:
46+
- entry adapters call `commands.build_parsed_intent(...)` + `commands.execute_parsed_intent(...)`
47+
- infra changes that keep `dispatch.execute(ctx)` as the single execute entry
48+
- Disallowed:
49+
- fallback branches such as `if commands.execute_parsed_intent then ... else ...`
50+
- building action context outside `commands.bind_action_context(...)`
51+
- adding per-entry behavior forks (`if source == 'keymap' then ...`)
52+
53+
## Quick Review Checklist
54+
55+
- Does new code bypass `dispatch.execute`?
56+
- Does new code create a second bind path?
57+
- Does parse start carrying execute logic again?
58+
- Are hook semantics consistent for all entries?
59+
60+
## Reject Conditions
61+
62+
- Any new execute entry besides `dispatch.execute`
63+
- Any new bind path besides `bind_action_context`
64+
- Parse starts binding execute functions again
65+
66+
## Minimal Regression Commands
67+
68+
- `./run_tests.sh -t tests/unit/commands_dispatch_spec.lua`
69+
- `./run_tests.sh -t tests/unit/commands_parse_spec.lua`
70+
- `./run_tests.sh -t tests/unit/commands/command_axis_spec.lua`
71+
- `./run_tests.sh -t tests/unit/keymap_spec.lua`
72+
- `./run_tests.sh -t tests/unit/api_spec.lua -f "command routing"`
73+
74+
## Entry Notes For New Agents
75+
76+
- Read `parse.lua`, `init.lua`, `dispatch.lua` in this order before editing.
77+
- Treat this directory as **execution infrastructure**, not feature surface.
78+
- If a change needs new behavior, prefer changing handlers first; only touch command infrastructure when all entries must change together.
79+
- Keep edits local and reversible: no new execution entry, no new bind path, no per-entry semantic split.

0 commit comments

Comments
 (0)