Skip to content

Commit 2ffec73

Browse files
author
oujinsai
committed
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
1 parent 138299d commit 2ffec73

28 files changed

+3419
-2008
lines changed

lua/opencode/api.lua

Lines changed: 117 additions & 1609 deletions
Large diffs are not rendered by default.

lua/opencode/commands/complete.lua

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
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+
---@class OpencodeCommandCompleteContext
31+
---@field arg_lead string
32+
---@field num_parts integer
33+
---@field subcmd_def OpencodeUICommand
34+
35+
---@class OpencodeCommandCompleteRule
36+
---@field matches fun(ctx: OpencodeCommandCompleteContext): boolean
37+
---@field resolve fun(ctx: OpencodeCommandCompleteContext): string[]
38+
39+
---@type OpencodeCommandCompleteRule[]
40+
local completion_rules = {
41+
{
42+
matches = function(ctx)
43+
return ctx.num_parts <= 3 and type(ctx.subcmd_def.completions) == 'table'
44+
end,
45+
resolve = function(ctx)
46+
return ctx.subcmd_def.completions --[[@as string[] ]]
47+
end,
48+
},
49+
{
50+
matches = function(ctx)
51+
return ctx.num_parts <= 3 and type(ctx.subcmd_def.completion_provider_id) == 'string'
52+
end,
53+
resolve = function(ctx)
54+
local provider_id = ctx.subcmd_def.completion_provider_id --[[@as string ]]
55+
local provider = provider_completions[provider_id]
56+
if not provider then
57+
return {}
58+
end
59+
return provider()
60+
end,
61+
},
62+
{
63+
matches = function(ctx)
64+
return ctx.num_parts <= 4 and type(ctx.subcmd_def.sub_completions) == 'table'
65+
end,
66+
resolve = function(ctx)
67+
return ctx.subcmd_def.sub_completions --[[@as string[] ]]
68+
end,
69+
},
70+
}
71+
72+
---@param command_definitions table<string, OpencodeUICommand>
73+
---@param arg_lead string
74+
---@param cmd_line string
75+
---@return string[]
76+
function M.complete_command(command_definitions, arg_lead, cmd_line)
77+
local parts = vim.split(cmd_line, '%s+', { trimempty = false })
78+
local num_parts = #parts
79+
80+
if num_parts <= 2 then
81+
local subcommands = vim.tbl_keys(command_definitions)
82+
table.sort(subcommands)
83+
return vim.tbl_filter(function(cmd)
84+
return vim.startswith(cmd, arg_lead)
85+
end, subcommands)
86+
end
87+
88+
local subcommand = parts[2]
89+
local subcmd_def = command_definitions[subcommand]
90+
91+
if not subcmd_def then
92+
return {}
93+
end
94+
95+
local ctx = {
96+
arg_lead = arg_lead,
97+
num_parts = num_parts,
98+
subcmd_def = subcmd_def,
99+
}
100+
101+
for _, rule in ipairs(completion_rules) do
102+
if rule.matches(ctx) then
103+
return filter_by_prefix(rule.resolve(ctx), arg_lead)
104+
end
105+
end
106+
107+
return {}
108+
end
109+
110+
return M

lua/opencode/commands/dispatch.lua

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
-- TODO: hooks pipeline is wired but not yet exposed in config defaults.
2+
-- Future: add on_command_before/after/error/finally to config.hooks so users
3+
-- can intercept commands. The custom.command.* events will also need subscribers.
4+
-- See docs/explorations/hooks-system.md for design intent.
5+
local config = require('opencode.config')
6+
local state = require('opencode.state')
7+
8+
local M = {}
9+
10+
local lifecycle_hook_keys = {
11+
before = 'on_command_before',
12+
after = 'on_command_after',
13+
error = 'on_command_error',
14+
finally = 'on_command_finally',
15+
}
16+
17+
local lifecycle_event_names = {
18+
before = 'custom.command.before',
19+
after = 'custom.command.after',
20+
error = 'custom.command.error',
21+
finally = 'custom.command.finally',
22+
}
23+
24+
---@param event_name string
25+
---@param payload table
26+
local function emit_lifecycle_event(event_name, payload)
27+
local manager = state.event_manager
28+
if manager and type(manager.emit) == 'function' then
29+
pcall(manager.emit, manager, event_name, payload)
30+
end
31+
end
32+
33+
---@param stage OpencodeCommandLifecycleStage
34+
---@param hook_id string
35+
---@param hook_fn OpencodeCommandDispatchHook
36+
---@param ctx OpencodeCommandDispatchContext
37+
---@return OpencodeCommandDispatchContext
38+
local function run_hook(stage, hook_id, hook_fn, ctx)
39+
local ok, next_ctx_or_err = pcall(hook_fn, ctx)
40+
if not ok then
41+
emit_lifecycle_event('custom.command.hook_error', {
42+
stage = stage,
43+
hook_id = hook_id,
44+
error = tostring(next_ctx_or_err),
45+
context = ctx,
46+
})
47+
return ctx
48+
end
49+
50+
if type(next_ctx_or_err) == 'table' then
51+
return next_ctx_or_err
52+
end
53+
54+
return ctx
55+
end
56+
57+
---@param stage OpencodeCommandLifecycleStage
58+
---@param ctx OpencodeCommandDispatchContext
59+
---@return OpencodeCommandDispatchContext
60+
local function run_hook_pipeline(stage, ctx)
61+
local next_ctx = ctx
62+
63+
local hooks = config.hooks
64+
if hooks then
65+
local config_hook_name = lifecycle_hook_keys[stage]
66+
local config_hook = hooks[config_hook_name]
67+
if type(config_hook) == 'function' then
68+
next_ctx = run_hook(stage, 'config:' .. config_hook_name, config_hook, next_ctx)
69+
end
70+
end
71+
72+
emit_lifecycle_event(lifecycle_event_names[stage], next_ctx)
73+
74+
return next_ctx
75+
end
76+
77+
---@param parsed OpencodeCommandParseResult
78+
---@return OpencodeCommandDispatchResult
79+
function M.dispatch_intent(parsed)
80+
---@type OpencodeCommandDispatchContext
81+
local ctx = {
82+
parsed = parsed,
83+
intent = parsed.intent,
84+
args = parsed.intent and parsed.intent.args or nil,
85+
range = parsed.intent and parsed.intent.range or nil,
86+
}
87+
88+
if not parsed.ok then
89+
ctx.error = {
90+
code = parsed.error.code,
91+
message = parsed.error.message,
92+
subcommand = parsed.error.subcommand,
93+
}
94+
ctx = run_hook_pipeline('error', ctx)
95+
ctx = run_hook_pipeline('finally', ctx)
96+
97+
return {
98+
ok = false,
99+
error = ctx.error,
100+
}
101+
end
102+
103+
ctx = run_hook_pipeline('before', ctx)
104+
105+
local intent = ctx.intent or parsed.intent
106+
if not intent then
107+
ctx.error = {
108+
code = 'missing_handler',
109+
message = 'Missing command intent',
110+
}
111+
ctx = run_hook_pipeline('error', ctx)
112+
ctx = run_hook_pipeline('finally', ctx)
113+
114+
return {
115+
ok = false,
116+
error = ctx.error,
117+
}
118+
end
119+
120+
local args = ctx.args or intent.args or {}
121+
local range = ctx.range or intent.range
122+
intent.args = args
123+
intent.range = range
124+
ctx.intent = intent
125+
126+
local execute_fn = intent.execute
127+
if not execute_fn then
128+
ctx.error = {
129+
code = 'missing_execute',
130+
message = 'Command has no execute function',
131+
}
132+
ctx = run_hook_pipeline('error', ctx)
133+
ctx = run_hook_pipeline('finally', ctx)
134+
135+
return {
136+
ok = false,
137+
intent = ctx.intent,
138+
error = ctx.error,
139+
}
140+
end
141+
142+
local ok, result_or_err = pcall(execute_fn, args, range)
143+
144+
if not ok then
145+
local err = result_or_err
146+
if type(err) == 'table' and err.code then
147+
ctx.error = err
148+
else
149+
ctx.error = {
150+
code = 'execute_error',
151+
message = tostring(err),
152+
}
153+
end
154+
ctx = run_hook_pipeline('error', ctx)
155+
ctx = run_hook_pipeline('finally', ctx)
156+
157+
return {
158+
ok = false,
159+
intent = ctx.intent,
160+
error = ctx.error,
161+
}
162+
end
163+
164+
ctx.result = result_or_err
165+
ctx = run_hook_pipeline('after', ctx)
166+
ctx = run_hook_pipeline('finally', ctx)
167+
168+
return {
169+
ok = true,
170+
result = ctx.result,
171+
intent = ctx.intent,
172+
}
173+
end
174+
175+
return M

0 commit comments

Comments
 (0)