Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,52 @@ jobs:
- name: Run type checks
run: make typecheck ARGS="-f github"

architecture:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
name: architecture check
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Install uv
uses: astral-sh/setup-uv@v6

- name: Check module imports against layer rules
id: check
env:
BASE_REF: ${{ github.base_ref }}
run: |
set +e
make -s topology-diff ARGS="--from origin/$BASE_REF --to HEAD --check --markdown" > architecture-report.md
echo "status=$?" >> "$GITHUB_OUTPUT"
cat architecture-report.md >> "$GITHUB_STEP_SUMMARY"

# Fork PRs get a read-only token; the job summary still carries the report.
- name: Post report on the pull request
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
[ -s architecture-report.md ] || exit 0
marker='<!-- architecture-check-report -->'
id=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" --paginate \
--jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n1)
if [ -n "$id" ]; then
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$id" -F body=@architecture-report.md
else
gh api "repos/$GITHUB_REPOSITORY/issues/$PR/comments" -F body=@architecture-report.md
fi

- name: Fail on new problems
if: steps.check.outputs.status != '0'
run: exit 1

test:
timeout-minutes: 4
strategy:
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Commands

`make help` lists all. Key: `check` (format-check + typecheck + test), `test [TEST=path]`, `typecheck`, `format-check`, `replay`, `topology`, `topology-diff`. Extra flags: `ARGS="..."`.
`make help` lists all. Key: `check` (format-check + typecheck + test), `test [TEST=path]`, `typecheck`, `format-check`, `replay`, `topology`, `topology-diff`, `topology-check`. Extra flags: `ARGS="..."`.

## Before Finishing Lua Changes (Mandatory)

Expand Down Expand Up @@ -33,4 +33,5 @@ Use scanner, not grep, for architecture/debt. Remove debt only after code + test

- `make topology`: gap vs target policy.
- `make topology-diff ARGS="--from main --to HEAD"`: improved/regressed/neutral.
- `make topology-check`: CI gate vs `origin/main`; fails on new layer-rule breaks, new/grown import loops, or modules missing from `topology.jsonc`.
- Flags: `--json`, `--snapshot <git-ref>`. Docs: `scripts/dependency-topology/README.md`.
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ PYTHON ?= python3

LUA_GLOBS := -g '*.lua' -g '!deps/'
TOPOLOGY := scripts/dependency-topology/scan_topology.py
TOPOLOGY_REQUIREMENTS := scripts/dependency-topology/requirements.txt
# uv fetches the scanner's Python deps on demand; without uv, install them into $(PYTHON) first.
TOPOLOGY_PYTHON ?= $(if $(shell command -v uv),uv run --quiet --no-project --with-requirements $(TOPOLOGY_REQUIREMENTS) python3,$(PYTHON))
TOPOLOGY_BASE ?= origin/main

.PHONY: help check test test-minimal test-unit test-replay typecheck format-check format replay replay-regenerate topology topology-diff
.PHONY: help check test test-minimal test-unit test-replay typecheck format-check format replay replay-regenerate topology topology-diff topology-check

help: ## List development commands
@awk 'BEGIN { FS = ":.*## " } /^[a-z-]+:.*## / { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST)
@printf '\nExamples:\n make test TEST=tests/unit/formatter_spec.lua\n make test TEST=unit FILTER="Timer"\n make typecheck ARGS="-f github"\n make replay ARGS="-c ReplayAll"\n make replay-regenerate FILE=v2/formatters.json\n make topology ARGS="--json"\n make topology-diff ARGS="--from main --to HEAD --json"\n'
@printf '\nExamples:\n make test TEST=tests/unit/formatter_spec.lua\n make test TEST=unit FILTER="Timer"\n make typecheck ARGS="-f github"\n make replay ARGS="-c ReplayAll"\n make replay-regenerate FILE=v2/formatters.json\n make topology ARGS="--json"\n make topology-diff ARGS="--from main --to HEAD --json"\n make topology-check TOPOLOGY_BASE=upstream/main\n'

check: format-check typecheck test ## Run formatting, type checks, and all tests

Expand Down Expand Up @@ -46,7 +50,10 @@ replay-regenerate: ## Regenerate snapshots with confirmation (optional FILE rela
env -u VIM -u VIMRUNTIME ./tests/manual/regenerate_expected.sh $(if $(FILE),"$(FILE)")

topology: ## Scan dependency topology (optional scanner ARGS)
$(PYTHON) $(TOPOLOGY) scan $(ARGS)
$(TOPOLOGY_PYTHON) $(TOPOLOGY) scan $(ARGS)

topology-diff: ## Compare dependency topology snapshots (optional scanner ARGS)
$(PYTHON) $(TOPOLOGY) diff $(ARGS)
$(TOPOLOGY_PYTHON) $(TOPOLOGY) diff $(ARGS)

topology-check: ## Fail on new layer-rule breaks, import loops, or modules without a layer (vs TOPOLOGY_BASE)
$(TOPOLOGY_PYTHON) $(TOPOLOGY) diff --from $(TOPOLOGY_BASE) --to worktree --check $(ARGS)
133 changes: 14 additions & 119 deletions lua/opencode/keymap.lua
Original file line number Diff line number Diff line change
@@ -1,113 +1,20 @@
local M = {}
local store = require('opencode.state.store')
local mapper = require('opencode.ui.keymap')
local window_keymaps = {}

local function normalize_lhs(lhs)
return vim.api.nvim_replace_termcodes(lhs, true, true, true)
end

local function is_completion_visible()
return require('opencode.ui.completion').is_completion_visible()
end

---@param key_binding string The key binding to feed if completion is visible
---@param callback function The callback to execute if completion is not visible
local function wrap_with_completion_check(key_binding, callback)
return function()
if is_completion_visible() then
return vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(key_binding, true, false, true), 'n', false)
end
return callback()
end
end

---@param func_name string|function
---@param func_args any
---@param actions? table<string, function?> Buffer-local overrides take precedence over panel commands
---@return function|nil
local function resolve_callback(func_name, func_args, actions)
if type(func_name) == 'string' and actions and actions[func_name] then
func_name = actions[func_name]
end
if type(func_name) == 'function' then
return function()
local args = func_args and (type(func_args) == 'table' and vim.deepcopy(func_args) or { func_args }) or {}
return func_name(unpack(args))
end
end

if type(func_name) == 'string' then
local commands = require('opencode.commands')
local command_defs = commands.get_commands()
if command_defs[func_name] then
return function()
local args = func_args and (type(func_args) == 'table' and vim.deepcopy(func_args) or { func_args }) or {}
local parsed = commands.build_parsed_intent(func_name, args)
return commands.execute_parsed_intent(parsed)
end
end

vim.notify('Cannot find keymap action: ' .. func_name, vim.log.levels.ERROR)
return nil
end

return nil
end

---@param keymap_config table The keymap configuration table
---@param default_modes table Default modes for these keymaps
---@param base_opts table Base options to use for all keymaps
---@param preserve_existing? boolean
---@param actions? table<string, function?>
local function process_keymap_entry(keymap_config, default_modes, base_opts, preserve_existing, actions)
-- Commands load diff handlers, which also use this mapper for their buffers.
mapper.resolve_command = function(name, args)
local commands = require('opencode.commands')
local command_defs = commands.get_commands()

for key_binding, config_entry in pairs(keymap_config) do
if config_entry == false then
-- Skip keymap if explicitly set to false (disabled)
elseif config_entry then
local func_name = config_entry[1]
local func_args = config_entry[2]
local callback = resolve_callback(func_name, func_args, actions)

local modes = config_entry.mode or default_modes
if preserve_existing and base_opts.buffer then
local missing_modes = {}
for _, mode in ipairs(type(modes) == 'table' and modes or { modes }) do
local exists = false
for _, mapping in ipairs(vim.api.nvim_buf_get_keymap(base_opts.buffer, mode)) do
if normalize_lhs(mapping.lhs) == normalize_lhs(key_binding) then
exists = true
break
end
end
if not exists then
table.insert(missing_modes, mode)
end
end
modes = missing_modes
end
local opts = vim.tbl_deep_extend('force', {}, base_opts)
opts.nowait = config_entry.nowait
local command_desc = type(func_name) == 'string'
and not (actions and actions[func_name])
and vim.tbl_get(command_defs, func_name, 'desc')
opts.desc = config_entry.desc or command_desc or ''

if not callback then
if type(func_name) ~= 'string' then
vim.notify(string.format('No action found for keymap: %s -> %s', key_binding, func_name), vim.log.levels.WARN)
end
elseif #modes > 0 then
if config_entry.defer_to_completion then
callback = wrap_with_completion_check(key_binding, callback)
end
vim.keymap.set(modes, key_binding, callback, opts)
end
end
local definition = commands.get_commands()[name]
if not definition then
vim.notify('Cannot find keymap action: ' .. name, vim.log.levels.ERROR)
return nil
end
return function()
local arguments = args and (type(args) == 'table' and vim.deepcopy(args) or { args }) or {}
return commands.execute_parsed_intent(commands.build_parsed_intent(name, arguments))
end,
definition.desc
end

local function setup_panel_keymaps(_, windows, previous)
Expand All @@ -123,14 +30,14 @@ local function setup_panel_keymaps(_, windows, previous)
and vim.api.nvim_buf_is_valid(buf)
and (name == 'tab_strip' or win and vim.api.nvim_win_is_valid(win))
then
M.setup_window_keymaps(window_keymaps[name .. '_window'], buf, true)
mapper.setup_window_keymaps(window_keymaps[name .. '_window'], buf, true)
end
end
end

---@param keymap OpencodeKeymap The keymap configuration table
---@param keymap OpencodeKeymap
function M.setup(keymap)
process_keymap_entry(keymap.editor or {}, { 'n', 'v' }, { silent = false })
mapper.setup_keymaps(keymap.editor or {}, { 'n', 'v' }, { silent = false })
window_keymaps = keymap
store.subscribe('windows', setup_panel_keymaps)
setup_panel_keymaps(nil, store.get('windows'))
Expand All @@ -141,16 +48,4 @@ function M.teardown()
window_keymaps = {}
end

---@param keymap_config table Window keymap configuration
---@param buf_id integer Buffer ID to set keymaps for
---@param preserve_existing? boolean
---@param actions? table<string, function?> Local action overrides; unknown names use command resolution and its error notification
function M.setup_window_keymaps(keymap_config, buf_id, preserve_existing, actions)
if not vim.api.nvim_buf_is_valid(buf_id) then
return
end

process_keymap_entry(keymap_config or {}, { 'n' }, { silent = true, buffer = buf_id }, preserve_existing, actions)
end

return M
11 changes: 10 additions & 1 deletion lua/opencode/protocols/observation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ local resource_names = {
---@field on_stream_error? fun(observation: OpencodeObservation, message: string)
---@field on_close? fun(observation: OpencodeObservation)

---@class OpencodeMessageSelection
---@field mode? string
---@field model? string
---@field variant? string
---@field default_mode? string
---@field default_model? string Server config model, used when no model is selected
---@field available_agents? string[] Primary agents; only these may update the tab mode

---@class OpencodeObservation
---@field _connection table
---@field _session_id string
Expand All @@ -70,7 +78,8 @@ local resource_names = {
---@field _event_revisions table<OpencodeObservedResource, integer>
---@field reply_permission fun(self: OpencodeObservation, request_id: string, answer: {choice: 'once'|'always'|'reject', message?: string}): Promise<boolean>
---@field validate_message_options fun(self: OpencodeObservation, opts: SendMessageOpts, default_system?: string)
---@field prepare_message fun(self: OpencodeObservation, opts: SendMessageOpts, selected: {mode?: string, model?: string, variant?: string, default_mode?: string, default_model?: string, available_agents?: string[]}): table, OpencodeSessionTabModelUpdate
---@field prepare_message fun(self: OpencodeObservation, opts: SendMessageOpts, selected: OpencodeMessageSelection): table, OpencodeSessionTabModelUpdate
---@field needs_message_defaults? boolean Caller must fill default_model/available_agents in the selection
local Observation = {}
Observation.__index = Observation

Expand Down
26 changes: 6 additions & 20 deletions lua/opencode/protocols/v1/observation.lua
Original file line number Diff line number Diff line change
Expand Up @@ -16,42 +16,27 @@ local lifecycle = require('opencode.protocols.observation')
local id = require('opencode.id')
local Promise = require('opencode.promise')
local util = require('opencode.util')
local config_file = require('opencode.config_file')
local event_capture = require('opencode.event_capture')

local M = {}

---@param opts SendMessageOpts
---@param selected {mode?: string, model?: string, variant?: string, default_mode?: string}
---@param selected OpencodeMessageSelection
---@return table, OpencodeSessionTabModelUpdate
function M.prepare_message(opts, selected)
if opts.skills and #opts.skills > 0 then
error('V1 submit does not support native skill attachments')
end
---@type {mode?: string, model?: string, variant?: string, default_mode?: string, default_model?: string, available_agents?: string[]}
local message_selection = {
mode = selected.mode,
model = selected.model,
variant = selected.variant,
default_mode = selected.default_mode,
}
if opts.model == nil and selected.model == nil then
local remote_config = config_file.get_opencode_config():await()
message_selection.default_model = remote_config and remote_config.model ~= '' and remote_config.model or nil
end
if opts.agent or message_selection.mode or message_selection.default_mode then
message_selection.available_agents = config_file.get_opencode_agents():await()
end

local explicit_model = opts.model ~= nil
if opts.agent == nil then
opts.agent = message_selection.mode or message_selection.default_mode
opts.agent = selected.mode or selected.default_mode
end
if opts.model == nil then
opts.model = message_selection.model or message_selection.default_model
opts.model = selected.model or selected.default_model
end
if opts.variant == nil then
opts.variant = message_selection.variant
opts.variant = selected.variant
end

local overrides = {}
Expand All @@ -74,7 +59,7 @@ function M.prepare_message(opts, selected)
end
if opts.agent then
overrides.agent = opts.agent
if vim.tbl_contains(message_selection.available_agents or {}, opts.agent) then
if vim.tbl_contains(selected.available_agents or {}, opts.agent) then
update.mode = opts.agent
end
end
Expand Down Expand Up @@ -1049,6 +1034,7 @@ function M.new(connection, ref)
observation._v1_history_complete = false
observation._v1_history_limit = 50
observation._v1_older_loading = false
observation.needs_message_defaults = true
function observation.prepare_message(_, opts, selected)
return M.prepare_message(opts, selected)
end
Expand Down
11 changes: 11 additions & 0 deletions lua/opencode/services/messaging.lua
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ local state = require('opencode.state')
local context = require('opencode.context')
local util = require('opencode.util')
local config = require('opencode.config')
local config_file = require('opencode.config_file')
local Promise = require('opencode.promise')
local log = require('opencode.log')
local session_runtime = require('opencode.services.session_runtime')
Expand Down Expand Up @@ -56,12 +57,22 @@ local function prepare_message(observation, prompt, opts)
end, submission_context.review_comments or {})
submission_context.automatic_context = {}
local previous_context = state.last_sent_context and vim.deepcopy(state.last_sent_context)
---@type OpencodeMessageSelection
local selected = {
mode = state.current_mode,
model = state.current_model,
variant = state.current_variant,
default_mode = config.default_mode,
}
if observation.needs_message_defaults then
if opts.model == nil and selected.model == nil then
local remote_config = config_file.get_opencode_config():await()
selected.default_model = remote_config and remote_config.model ~= '' and remote_config.model or nil
end
if opts.agent or selected.mode or selected.default_mode then
selected.available_agents = config_file.get_opencode_agents():await()
end
end
local overrides, model_update = observation:prepare_message(opts, selected)
local params = context
.format_message(prompt, opts.context, {
Expand Down
2 changes: 1 addition & 1 deletion lua/opencode/ui/formatter/utils.lua
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ function M.add_tool_diff_action(output, message, context, path, first_line, last
if not context or not context.interactive or not message then
return
end
local connection = require('opencode.state').opencode_server
local connection = require('opencode.state.store').get('opencode_server')
if not connection or connection.protocol ~= 'v2' then
return
end
Expand Down
Loading
Loading