Skip to content

Commit 519e95f

Browse files
jensenojsoujinsai
andauthored
refactor(commands): split api into parse/dispatch/handler boundaries (#337)
* refactor(commands): carve api into parse/dispatch/handler layers - extract command flow into parse, dispatch, complete, and slash modules - move domain behaviors into handlers (window, workflow, session, diff, surface, agent, permission) - standardize command_defs around execute/completions and add duplicate-definition fail-fast checks - route keymap/slash entry points through the same command boundary - refresh command-layer tests to lock parse/dispatch/handler contracts * fix * 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 * fix(commands): address copilot review findings - include allowed subcommands in agent/session/diff invalid argument errors - clamp help table column width in narrow windows to avoid format failures - fail run_tests.sh on non-zero nvim/plenary exits in addition to output parsing - add handlers tests for actionable subcommand errors and narrow-window help rendering --------- Co-authored-by: oujinsai <[email protected]>
1 parent a52636b commit 519e95f

32 files changed

Lines changed: 4274 additions & 2020 deletions

lua/opencode/api.lua

Lines changed: 118 additions & 1587 deletions
Large diffs are not rendered by default.

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.

lua/opencode/commands/complete.lua

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
local M = {}
2+
3+
---@param items string[]
4+
---@param prefix string
5+
---@return string[]
6+
local function filter_by_prefix(items, prefix)
7+
return vim.tbl_filter(function(item)
8+
return vim.startswith(item, prefix)
9+
end, items)
10+
end
11+
12+
---@return string[]
13+
local function user_command_completions()
14+
local config_file = require('opencode.config_file')
15+
local user_commands = config_file.get_user_commands():wait()
16+
if not user_commands then
17+
return {}
18+
end
19+
20+
local names = vim.tbl_keys(user_commands)
21+
table.sort(names)
22+
return names
23+
end
24+
25+
---@type table<string, fun(): string[]>
26+
local provider_completions = {
27+
user_commands = user_command_completions,
28+
}
29+
30+
---@param subcmd_def OpencodeUICommand
31+
---@param num_parts integer
32+
---@return string[]
33+
local function resolve_subcommand_completions(subcmd_def, num_parts)
34+
if num_parts <= 3 then
35+
if type(subcmd_def.completions) == 'table' then
36+
return subcmd_def.completions --[[@as string[] ]]
37+
end
38+
39+
if type(subcmd_def.completion_provider_id) == 'string' then
40+
local provider = provider_completions[subcmd_def.completion_provider_id]
41+
return provider and provider() or {}
42+
end
43+
end
44+
45+
if num_parts <= 4 and type(subcmd_def.sub_completions) == 'table' then
46+
return subcmd_def.sub_completions --[[@as string[] ]]
47+
end
48+
49+
return {}
50+
end
51+
52+
---@param command_definitions table<string, OpencodeUICommand>
53+
---@param arg_lead string
54+
---@param cmd_line string
55+
---@return string[]
56+
function M.complete_command(command_definitions, arg_lead, cmd_line)
57+
local parts = vim.split(cmd_line, '%s+', { trimempty = false })
58+
local num_parts = #parts
59+
60+
if num_parts <= 2 then
61+
local subcommands = vim.tbl_keys(command_definitions)
62+
table.sort(subcommands)
63+
return vim.tbl_filter(function(cmd)
64+
return vim.startswith(cmd, arg_lead)
65+
end, subcommands)
66+
end
67+
68+
local subcommand = parts[2]
69+
local subcmd_def = command_definitions[subcommand]
70+
71+
if not subcmd_def then
72+
return {}
73+
end
74+
75+
return filter_by_prefix(resolve_subcommand_completions(subcmd_def, num_parts), arg_lead)
76+
end
77+
78+
return M

0 commit comments

Comments
 (0)