Skip to content

Commit 4cb4261

Browse files
Merge upstream/main into feature/external-server
Resolved conflict in event_manager.lua by combining: - Upstream's normalized_events processing - Our nil-checking for event.type to prevent errors All other files merged automatically.
2 parents 12a7ec6 + 7ed41d1 commit 4cb4261

22 files changed

Lines changed: 1868 additions & 176 deletions

AGENTS.md

Lines changed: 3 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,61 +3,11 @@
33
## Build, Lint, and Test
44

55
- **Run all tests:** `./run_tests.sh`
6-
- **Minimal tests:** `./run_tests.sh -t minimal`
7-
`nvim --headless -u tests/minimal/init.lua -c "lua require('plenary.test_harness').test_directory('./tests/minimal', {minimal_init = './tests/minimal/init.lua', sequential = true})"`
8-
- **Unit tests:** `./run_tests.sh -t unit`
9-
`nvim --headless -u tests/minimal/init.lua -c "lua require('plenary.test_harness').test_directory('./tests/unit', {minimal_init = './tests/minimal/init.lua'})"`
10-
- **Replay tests:** `./run_tests.sh -t replay`
11-
`nvim --headless -u tests/minimal/init.lua -c "lua require('plenary.test_harness').test_directory('./tests/replay', {minimal_init = './tests/minimal/init.lua'})"`
126
- **Run a single test:** Replace the directory in the above command with the test file path, e.g.:
13-
`nvim --headless -u tests/manual/init.lua -c "lua require('plenary.test_harness').test_directory('./tests/unit/job_spec.lua', {minimal_init = './tests/minimal/init.lua'})"`
14-
- **Manual/Visual tests:** `./tests/manual/run_replay.sh` - Replay captured event data for visual testing
15-
- **Debug rendering in headless mode:**
16-
`nvim --headless -u tests/manual/init_replay.lua "+ReplayHeadless" "+ReplayLoad tests/data/FILE.json" "+ReplayAll 0" "+qa"`
17-
This will replay events and dump the output buffer to stdout, useful for debugging rendering issues without a UI.
18-
You can also run to just a specific message # with (e.g. message # 12):
19-
`nvim --headless -u tests/manual/init_replay.lua "+ReplayHeadless" "+ReplayLoad tests/data/message-removal.json" "+ReplayNext 12" "+qa"`
20-
- **Lint:** No explicit lint command; follow Lua best practices.
7+
- `./run_tests.sh -t tests/unit/test_example.lua`
218

229
## Code Style Guidelines
2310

24-
- **Imports:** `local mod = require('mod')` at the top. Group standard, then project imports.
25-
- **Formatting:** 2 spaces per indent. No trailing whitespace. Lines ≤ 100 chars.
26-
- **Types:** Use Lua annotations (`---@class`, `---@field`, etc.) for public APIs/config.
27-
- **Naming:** Modules: `snake_case.lua`; functions/vars: `snake_case`; classes: `CamelCase`.
28-
- **Error Handling:** Use `vim.notify` for user-facing errors. Return early on error.
29-
- **Comments:** Avoid obvious comments that merely restate what the code does. Only add comments when necessary to explain *why* something is done, not *what* is being done. Prefer self-explanatory code.
30-
- **Functions:** Prefer local functions. Use `M.func` for module exports.
11+
- **Comments:** Avoid obvious comments that merely restate what the code does. Only add comments when necessary to explain _why_ something is done, not _what_ is being done. Prefer self-explanatory code.
3112
- **Config:** Centralize in `config.lua`. Use deep merge for user overrides.
32-
- **Tests:** Place in `tests/minimal/`, `tests/unit/`, or `tests/replay/`. Manual/visual tests in `tests/manual/`.
33-
34-
_Agentic coding agents must follow these conventions strictly for consistency and reliability._
35-
36-
## File Reference Detection
37-
38-
The plugin automatically detects file references in LLM responses and makes them navigable via the reference picker (`<leader>or` or `:Opencode references`).
39-
40-
### Supported Formats
41-
42-
The reference picker recognizes these file reference patterns:
43-
44-
1. **Backtick-wrapped** (recommended by LLMs naturally):
45-
- `` `path/to/file.lua` ``
46-
- `` `path/to/file.lua:42` ``
47-
- `` `path/to/file.lua:42:10` `` (with column)
48-
- `` `path/to/file.lua:42-50` `` (line range)
49-
50-
2. **file:// URIs** (backward compatibility):
51-
- `file://path/to/file.lua`
52-
- `file://path/to/file.lua:42`
53-
- `file://path/to/file.lua:42-50`
54-
55-
3. **Plain paths** (natural format):
56-
- `path/to/file.lua`
57-
- `path/to/file.lua:42`
58-
- `./relative/path.lua:42`
59-
- `/absolute/path.lua:42`
60-
61-
All formats support both relative and absolute paths. Files must exist to be recognized (validation prevents false positives).
62-
63-
**No system prompt configuration is required** - the parser works with all LLM providers, including those without system prompt support.
13+
- **Types:** Use Lua annotations (`---@class`, `---@field`, etc.) for public APIs/config.

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ require('opencode').setup({
216216
},
217217
},
218218
ui = {
219+
enable_treesitter_markdown = true, -- Use Treesitter for markdown rendering in the output window (default: true).
219220
position = 'right', -- 'right' (default), 'left' or 'current'. Position of the UI split. 'current' uses the current window for the output.
220221
input_position = 'bottom', -- 'bottom' (default) or 'top'. Position of the input window
221222
window_width = 0.40, -- Width as percentage of editor width
@@ -224,6 +225,7 @@ require('opencode').setup({
224225
display_context_size = true, -- Display context size in the footer
225226
display_cost = true, -- Display cost in the footer
226227
window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', -- Highlight group for the opencode window
228+
persist_state = true, -- Keep buffers when toggling/closing UI so window state restores quickly
227229
icons = {
228230
preset = 'nerdfonts', -- 'nerdfonts' | 'text'. Choose UI icon style (default: 'nerdfonts')
229231
overrides = {}, -- Optional per-key overrides, see section below
@@ -456,6 +458,19 @@ Available icon keys (see implementation at lua/opencode/ui/icons.lua lines 7-29)
456458
- status_on, status_off
457459
- border, bullet
458460

461+
### Window Persistence Behavior
462+
463+
`ui.persist_state` controls how `toggle` behaves:
464+
465+
- `persist_state = true` (default): `toggle()` hides/restores the UI and keeps buffers/session view in memory for fast restore.
466+
- `persist_state = false`: `toggle()` fully tears down UI buffers and recreates them on next open.
467+
468+
Related APIs:
469+
470+
- `require('opencode.api').toggle()` follows the `persist_state` behavior above.
471+
- `require('opencode.api').close()` always fully closes and clears hidden snapshot state.
472+
- `require('opencode.api').hide()` preserves buffers only when `persist_state = true`; otherwise it behaves like close.
473+
459474
### Picker Layout
460475

461476
You can customize the layout of the picker used for history, session, references, and timeline
@@ -637,6 +652,7 @@ The plugin provides the following actions that can be triggered via keymaps, com
637652
- `open_input` (boolean, default: `true`): Whether to open the input window after adding the selection. Set to `false` to add selection silently without changing focus.
638653

639654
Example keymap for silent add:
655+
640656
```lua
641657
['<leader>oY'] = { 'add_visual_selection', { open_input = false }, mode = {'v'} }
642658
```

lua/opencode/api.lua

Lines changed: 89 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -48,40 +48,101 @@ function M.close()
4848
return
4949
end
5050

51-
ui.close_windows(state.windows)
51+
ui.teardown_visible_windows(state.windows)
52+
end
53+
54+
function M.hide()
55+
ui.hide_visible_windows(state.windows)
5256
end
5357

5458
function M.paste_image()
5559
core.paste_image_from_clipboard()
5660
end
5761

58-
--- Check if opencode windows are in the current tab page
59-
--- @return boolean
60-
local function are_windows_in_current_tab()
61-
if not state.windows or not state.windows.output_win then
62-
return false
62+
---@return {status: 'closed'|'hidden'|'visible', position: string, windows: OpencodeWindowState|nil, cursor_positions: {input: integer[]|nil, output: integer[]|nil}}
63+
function M.get_window_state()
64+
return state.get_window_state()
65+
end
66+
67+
---@param hidden OpencodeHiddenBuffers|nil
68+
---@return 'input'|'output'
69+
local function resolve_hidden_focus(hidden)
70+
if hidden and (hidden.focused_window == 'input' or hidden.focused_window == 'output') then
71+
return hidden.focused_window
72+
end
73+
74+
if hidden and hidden.input_hidden then
75+
return 'output'
76+
end
77+
78+
return 'input'
79+
end
80+
81+
---@param restore_hidden boolean
82+
---@return {focus: 'input'|'output', open_action: 'reuse_visible'|'restore_hidden'|'create_fresh'}
83+
local function build_toggle_open_context(restore_hidden)
84+
if restore_hidden then
85+
local hidden = state.inspect_hidden_buffers()
86+
return {
87+
focus = resolve_hidden_focus(hidden),
88+
open_action = 'restore_hidden',
89+
}
6390
end
6491

65-
local current_tab = vim.api.nvim_get_current_tabpage()
66-
local ok, win_tab = pcall(vim.api.nvim_win_get_tabpage, state.windows.output_win)
67-
return ok and win_tab == current_tab
92+
local focus = config.ui.input.auto_hide and 'input'
93+
or state.last_focused_opencode_window
94+
or 'input'
95+
96+
return {
97+
focus = focus,
98+
open_action = 'create_fresh',
99+
}
68100
end
69101

70102
M.toggle = Promise.async(function(new_session)
71-
-- When auto_hide input is enabled, always focus input; otherwise use last focused
72-
local focus = 'input' ---@cast focus 'input' | 'output'
73-
if not config.ui.input.auto_hide then
74-
focus = state.last_focused_opencode_window or 'input'
103+
local decision = state.resolve_toggle_decision(
104+
config.ui.persist_state,
105+
state.display_route ~= nil
106+
)
107+
local action = decision.action
108+
local is_new_session = new_session == true
109+
110+
local function open_windows(restore_hidden)
111+
local ctx = build_toggle_open_context(restore_hidden == true)
112+
return core.open({
113+
new_session = is_new_session,
114+
focus = ctx.focus,
115+
start_insert = false,
116+
open_action = ctx.open_action,
117+
}):await()
75118
end
76119

77-
if state.windows == nil or not are_windows_in_current_tab() then
120+
local function open_fresh_windows()
121+
return open_windows(false)
122+
end
123+
124+
local function restore_hidden_windows()
125+
return open_windows(true)
126+
end
127+
128+
local function migrate_windows()
78129
if state.windows then
79-
M.close()
130+
ui.teardown_visible_windows(state.windows)
80131
end
81-
core.open({ new_session = new_session == true, focus = focus, start_insert = false }):await()
82-
else
83-
M.close()
132+
return open_fresh_windows()
84133
end
134+
135+
local action_handlers = {
136+
close = M.close,
137+
hide = M.hide,
138+
close_hidden = ui.drop_hidden_snapshot,
139+
migrate = migrate_windows,
140+
restore_hidden = restore_hidden_windows,
141+
open = open_fresh_windows,
142+
}
143+
144+
local handler = action_handlers[action] or action_handlers.open
145+
return handler()
85146
end)
86147

87148
---@param new_session boolean?
@@ -271,7 +332,7 @@ function M.set_review_breakpoint()
271332
end
272333

273334
function M.prev_history()
274-
if not state.windows then
335+
if not state.is_visible() then
275336
return
276337
end
277338
local prev_prompt = history.prev()
@@ -282,7 +343,7 @@ function M.prev_history()
282343
end
283344

284345
function M.next_history()
285-
if not state.windows then
346+
if not state.is_visible() then
286347
return
287348
end
288349
local next_prompt = history.next()
@@ -517,7 +578,7 @@ function M.help()
517578
'|--------------|-------------|',
518579
}, false)
519580

520-
if not state.windows or not state.windows.output_win then
581+
if not state.is_visible() or not state.windows.output_win then
521582
return
522583
end
523584

@@ -1051,6 +1112,13 @@ M.commands = {
10511112
end,
10521113
},
10531114

1115+
hide = {
1116+
desc = 'Hide opencode windows (preserve buffers for fast restore)',
1117+
fn = function(args)
1118+
M.hide()
1119+
end,
1120+
},
1121+
10541122
cancel = {
10551123
desc = 'Cancel running request',
10561124
fn = M.cancel,

lua/opencode/config.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ M.defaults = {
119119
},
120120
},
121121
ui = {
122+
enable_treesitter_markdown = true,
122123
position = 'right',
123124
input_position = 'bottom',
124125
window_width = 0.40,
@@ -128,6 +129,7 @@ M.defaults = {
128129
display_context_size = true,
129130
display_cost = true,
130131
window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder',
132+
persist_state = true,
131133
icons = {
132134
preset = 'nerdfonts',
133135
overrides = {},

0 commit comments

Comments
 (0)