@xinleibird/bridge-opencode 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @xinleibird/bridge-opencode might be problematic. Click here for more details.

@@ -0,0 +1,3 @@
1
+ [alias]
2
+ check-all = "check --features napi"
3
+ build-release = "build --release --features napi"
package/Cargo.lock ADDED
@@ -0,0 +1,386 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "aho-corasick"
7
+ version = "1.1.4"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10
+ dependencies = [
11
+ "memchr",
12
+ ]
13
+
14
+ [[package]]
15
+ name = "anyhow"
16
+ version = "1.0.100"
17
+ source = "registry+https://github.com/rust-lang/crates.io-index"
18
+ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
19
+
20
+ [[package]]
21
+ name = "autocfg"
22
+ version = "1.5.1"
23
+ source = "registry+https://github.com/rust-lang/crates.io-index"
24
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
25
+
26
+ [[package]]
27
+ name = "bitflags"
28
+ version = "2.11.1"
29
+ source = "registry+https://github.com/rust-lang/crates.io-index"
30
+ checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
31
+
32
+ [[package]]
33
+ name = "bridge-opencode"
34
+ version = "0.0.1"
35
+ dependencies = [
36
+ "anyhow",
37
+ "glob",
38
+ "napi",
39
+ "napi-build",
40
+ "napi-derive",
41
+ "neovim-lib",
42
+ "rmp",
43
+ "serde",
44
+ "serde_json",
45
+ ]
46
+
47
+ [[package]]
48
+ name = "byteorder"
49
+ version = "1.5.0"
50
+ source = "registry+https://github.com/rust-lang/crates.io-index"
51
+ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
52
+
53
+ [[package]]
54
+ name = "cfg-if"
55
+ version = "0.1.10"
56
+ source = "registry+https://github.com/rust-lang/crates.io-index"
57
+ checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
58
+
59
+ [[package]]
60
+ name = "cfg-if"
61
+ version = "1.0.4"
62
+ source = "registry+https://github.com/rust-lang/crates.io-index"
63
+ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
64
+
65
+ [[package]]
66
+ name = "convert_case"
67
+ version = "0.6.0"
68
+ source = "registry+https://github.com/rust-lang/crates.io-index"
69
+ checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
70
+ dependencies = [
71
+ "unicode-segmentation",
72
+ ]
73
+
74
+ [[package]]
75
+ name = "ctor"
76
+ version = "0.2.9"
77
+ source = "registry+https://github.com/rust-lang/crates.io-index"
78
+ checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501"
79
+ dependencies = [
80
+ "quote",
81
+ "syn",
82
+ ]
83
+
84
+ [[package]]
85
+ name = "glob"
86
+ version = "0.3.3"
87
+ source = "registry+https://github.com/rust-lang/crates.io-index"
88
+ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
89
+
90
+ [[package]]
91
+ name = "itoa"
92
+ version = "1.0.18"
93
+ source = "registry+https://github.com/rust-lang/crates.io-index"
94
+ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
95
+
96
+ [[package]]
97
+ name = "libc"
98
+ version = "0.2.186"
99
+ source = "registry+https://github.com/rust-lang/crates.io-index"
100
+ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
101
+
102
+ [[package]]
103
+ name = "libloading"
104
+ version = "0.8.9"
105
+ source = "registry+https://github.com/rust-lang/crates.io-index"
106
+ checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
107
+ dependencies = [
108
+ "cfg-if 1.0.4",
109
+ "windows-link",
110
+ ]
111
+
112
+ [[package]]
113
+ name = "log"
114
+ version = "0.4.29"
115
+ source = "registry+https://github.com/rust-lang/crates.io-index"
116
+ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
117
+
118
+ [[package]]
119
+ name = "memchr"
120
+ version = "2.8.0"
121
+ source = "registry+https://github.com/rust-lang/crates.io-index"
122
+ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
123
+
124
+ [[package]]
125
+ name = "napi"
126
+ version = "2.16.17"
127
+ source = "registry+https://github.com/rust-lang/crates.io-index"
128
+ checksum = "55740c4ae1d8696773c78fdafd5d0e5fe9bc9f1b071c7ba493ba5c413a9184f3"
129
+ dependencies = [
130
+ "bitflags",
131
+ "ctor",
132
+ "napi-derive",
133
+ "napi-sys",
134
+ "once_cell",
135
+ ]
136
+
137
+ [[package]]
138
+ name = "napi-build"
139
+ version = "2.3.2"
140
+ source = "registry+https://github.com/rust-lang/crates.io-index"
141
+ checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
142
+
143
+ [[package]]
144
+ name = "napi-derive"
145
+ version = "2.16.13"
146
+ source = "registry+https://github.com/rust-lang/crates.io-index"
147
+ checksum = "7cbe2585d8ac223f7d34f13701434b9d5f4eb9c332cccce8dee57ea18ab8ab0c"
148
+ dependencies = [
149
+ "cfg-if 1.0.4",
150
+ "convert_case",
151
+ "napi-derive-backend",
152
+ "proc-macro2",
153
+ "quote",
154
+ "syn",
155
+ ]
156
+
157
+ [[package]]
158
+ name = "napi-derive-backend"
159
+ version = "1.0.75"
160
+ source = "registry+https://github.com/rust-lang/crates.io-index"
161
+ checksum = "1639aaa9eeb76e91c6ae66da8ce3e89e921cd3885e99ec85f4abacae72fc91bf"
162
+ dependencies = [
163
+ "convert_case",
164
+ "once_cell",
165
+ "proc-macro2",
166
+ "quote",
167
+ "regex",
168
+ "semver",
169
+ "syn",
170
+ ]
171
+
172
+ [[package]]
173
+ name = "napi-sys"
174
+ version = "2.4.0"
175
+ source = "registry+https://github.com/rust-lang/crates.io-index"
176
+ checksum = "427802e8ec3a734331fec1035594a210ce1ff4dc5bc1950530920ab717964ea3"
177
+ dependencies = [
178
+ "libloading",
179
+ ]
180
+
181
+ [[package]]
182
+ name = "neovim-lib"
183
+ version = "0.6.1"
184
+ source = "registry+https://github.com/rust-lang/crates.io-index"
185
+ checksum = "d6a8f5a1e1be160ce2b669c2c495a34ade6f3a525d4afafd7370c1792070f587"
186
+ dependencies = [
187
+ "log",
188
+ "rmp",
189
+ "rmpv",
190
+ "unix_socket",
191
+ ]
192
+
193
+ [[package]]
194
+ name = "num-traits"
195
+ version = "0.2.19"
196
+ source = "registry+https://github.com/rust-lang/crates.io-index"
197
+ checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
198
+ dependencies = [
199
+ "autocfg",
200
+ ]
201
+
202
+ [[package]]
203
+ name = "once_cell"
204
+ version = "1.21.4"
205
+ source = "registry+https://github.com/rust-lang/crates.io-index"
206
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
207
+
208
+ [[package]]
209
+ name = "paste"
210
+ version = "1.0.15"
211
+ source = "registry+https://github.com/rust-lang/crates.io-index"
212
+ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
213
+
214
+ [[package]]
215
+ name = "proc-macro2"
216
+ version = "1.0.105"
217
+ source = "registry+https://github.com/rust-lang/crates.io-index"
218
+ checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
219
+ dependencies = [
220
+ "unicode-ident",
221
+ ]
222
+
223
+ [[package]]
224
+ name = "quote"
225
+ version = "1.0.45"
226
+ source = "registry+https://github.com/rust-lang/crates.io-index"
227
+ checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
228
+ dependencies = [
229
+ "proc-macro2",
230
+ ]
231
+
232
+ [[package]]
233
+ name = "regex"
234
+ version = "1.12.3"
235
+ source = "registry+https://github.com/rust-lang/crates.io-index"
236
+ checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
237
+ dependencies = [
238
+ "aho-corasick",
239
+ "memchr",
240
+ "regex-automata",
241
+ "regex-syntax",
242
+ ]
243
+
244
+ [[package]]
245
+ name = "regex-automata"
246
+ version = "0.4.14"
247
+ source = "registry+https://github.com/rust-lang/crates.io-index"
248
+ checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
249
+ dependencies = [
250
+ "aho-corasick",
251
+ "memchr",
252
+ "regex-syntax",
253
+ ]
254
+
255
+ [[package]]
256
+ name = "regex-syntax"
257
+ version = "0.8.10"
258
+ source = "registry+https://github.com/rust-lang/crates.io-index"
259
+ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
260
+
261
+ [[package]]
262
+ name = "rmp"
263
+ version = "0.8.14"
264
+ source = "registry+https://github.com/rust-lang/crates.io-index"
265
+ checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4"
266
+ dependencies = [
267
+ "byteorder",
268
+ "num-traits",
269
+ "paste",
270
+ ]
271
+
272
+ [[package]]
273
+ name = "rmpv"
274
+ version = "0.4.7"
275
+ source = "registry+https://github.com/rust-lang/crates.io-index"
276
+ checksum = "7c760afe11955e16121e36485b6b828326c3f0eaff1c31758d96dbeb5cf09fd5"
277
+ dependencies = [
278
+ "num-traits",
279
+ "rmp",
280
+ "serde",
281
+ "serde_bytes",
282
+ ]
283
+
284
+ [[package]]
285
+ name = "semver"
286
+ version = "1.0.28"
287
+ source = "registry+https://github.com/rust-lang/crates.io-index"
288
+ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
289
+
290
+ [[package]]
291
+ name = "serde"
292
+ version = "1.0.228"
293
+ source = "registry+https://github.com/rust-lang/crates.io-index"
294
+ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
295
+ dependencies = [
296
+ "serde_core",
297
+ "serde_derive",
298
+ ]
299
+
300
+ [[package]]
301
+ name = "serde_bytes"
302
+ version = "0.11.19"
303
+ source = "registry+https://github.com/rust-lang/crates.io-index"
304
+ checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
305
+ dependencies = [
306
+ "serde",
307
+ "serde_core",
308
+ ]
309
+
310
+ [[package]]
311
+ name = "serde_core"
312
+ version = "1.0.228"
313
+ source = "registry+https://github.com/rust-lang/crates.io-index"
314
+ checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
315
+ dependencies = [
316
+ "serde_derive",
317
+ ]
318
+
319
+ [[package]]
320
+ name = "serde_derive"
321
+ version = "1.0.228"
322
+ source = "registry+https://github.com/rust-lang/crates.io-index"
323
+ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
324
+ dependencies = [
325
+ "proc-macro2",
326
+ "quote",
327
+ "syn",
328
+ ]
329
+
330
+ [[package]]
331
+ name = "serde_json"
332
+ version = "1.0.150"
333
+ source = "registry+https://github.com/rust-lang/crates.io-index"
334
+ checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
335
+ dependencies = [
336
+ "itoa",
337
+ "memchr",
338
+ "serde",
339
+ "serde_core",
340
+ "zmij",
341
+ ]
342
+
343
+ [[package]]
344
+ name = "syn"
345
+ version = "2.0.114"
346
+ source = "registry+https://github.com/rust-lang/crates.io-index"
347
+ checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
348
+ dependencies = [
349
+ "proc-macro2",
350
+ "quote",
351
+ "unicode-ident",
352
+ ]
353
+
354
+ [[package]]
355
+ name = "unicode-ident"
356
+ version = "1.0.22"
357
+ source = "registry+https://github.com/rust-lang/crates.io-index"
358
+ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
359
+
360
+ [[package]]
361
+ name = "unicode-segmentation"
362
+ version = "1.13.2"
363
+ source = "registry+https://github.com/rust-lang/crates.io-index"
364
+ checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
365
+
366
+ [[package]]
367
+ name = "unix_socket"
368
+ version = "0.5.0"
369
+ source = "registry+https://github.com/rust-lang/crates.io-index"
370
+ checksum = "6aa2700417c405c38f5e6902d699345241c28c0b7ade4abaad71e35a87eb1564"
371
+ dependencies = [
372
+ "cfg-if 0.1.10",
373
+ "libc",
374
+ ]
375
+
376
+ [[package]]
377
+ name = "windows-link"
378
+ version = "0.2.1"
379
+ source = "registry+https://github.com/rust-lang/crates.io-index"
380
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
381
+
382
+ [[package]]
383
+ name = "zmij"
384
+ version = "1.0.21"
385
+ source = "registry+https://github.com/rust-lang/crates.io-index"
386
+ checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
package/Cargo.toml ADDED
@@ -0,0 +1,26 @@
1
+ [package]
2
+ name = "bridge-opencode"
3
+ version = "0.0.1"
4
+ edition = "2024"
5
+
6
+ [lib]
7
+ crate-type = ["cdylib", "lib"]
8
+
9
+ [build-dependencies]
10
+ napi-build = "2"
11
+
12
+ [features]
13
+ default = []
14
+ napi = ["dep:napi", "dep:napi-derive"]
15
+
16
+ [dependencies]
17
+ serde = { version = "1.0", features = ["derive"] }
18
+ serde_json = "1.0"
19
+ anyhow = "1.0"
20
+ neovim-lib = "0.6"
21
+ glob = "0.3"
22
+ napi = { version = "2", default-features = false, features = ["napi6"], optional = true }
23
+ napi-derive = { version = "2", optional = true }
24
+
25
+ # Pin rmp to avoid breaking changes in 0.8.15 that break rmpv 0.4.7 (used by neovim-lib)
26
+ rmp = "=0.8.14"
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Nishant Joshi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # bridge-opencode
2
+
3
+ Bridge between opencode and Neovim.
4
+
5
+ Inspired by / forked from [sidekick](https://github.com/NishantJoshi00/sidekick) — thanks [@NishantJoshi00](https://github.com/NishantJoshi00).
6
+
7
+ ## Structure
8
+
9
+ - `plugin/bridge.lua` — Neovim plugin, starts msgpack-rpc socket
10
+ - `bridge.ts` — opencode plugin entry (npm package main)
11
+ - `src/` — Rust source (napi-rs native addon)
12
+
13
+ ## Install
14
+
15
+ ### Neovim plugin (lazy.nvim)
16
+
17
+ ```lua
18
+ {
19
+ "xinleibird/bridge-opencode",
20
+ priority = 1000,
21
+ lazy = false,
22
+ }
23
+ ```
24
+
25
+ `plugin/bridge.lua` auto-starts the RPC socket when Neovim launches.
26
+
27
+ ### opencode plugin
28
+
29
+ ```sh
30
+ cd ~/.config/opencode && npm install @xinleibird/bridge-opencode
31
+ ```
32
+
33
+ ```json
34
+ {
35
+ "plugin": ["@xinleibird/bridge-opencode"]
36
+ }
37
+ ```
38
+
39
+ The `postinstall` script compiles the Rust native addon via napi-rs.
package/bridge.ts ADDED
@@ -0,0 +1,128 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import { isAbsolute, join } from "node:path";
3
+ import {
4
+ checkBuffer,
5
+ refreshBuffer,
6
+ getVisualSelections,
7
+ } from "./bridge-opencode.js";
8
+
9
+ type ToolName = "Edit" | "Write";
10
+
11
+ const TOOL_MAP: Record<string, ToolName> = {
12
+ edit: "Edit",
13
+ write: "Write",
14
+ };
15
+
16
+ const PATCH_FILE_MARKERS = [
17
+ "*** Add File:",
18
+ "*** Update File:",
19
+ "*** Delete File:",
20
+ "*** Move to:",
21
+ ];
22
+
23
+ function pickFilePath(args: unknown): string | null {
24
+ if (!args || typeof args !== "object") return null;
25
+ const candidate = (args as Record<string, unknown>).filePath;
26
+ return typeof candidate === "string" && candidate.length > 0
27
+ ? candidate
28
+ : null;
29
+ }
30
+
31
+ function pickPatchPaths(args: unknown): string[] {
32
+ if (!args || typeof args !== "object") return [];
33
+ const patch = (args as Record<string, unknown>).patchText;
34
+ if (typeof patch !== "string") return [];
35
+
36
+ const paths: string[] = [];
37
+ for (const line of patch.split("\n")) {
38
+ const trimmed = line.trimStart();
39
+ for (const marker of PATCH_FILE_MARKERS) {
40
+ if (trimmed.startsWith(marker)) {
41
+ const candidate = trimmed.slice(marker.length).trim();
42
+ if (candidate) paths.push(candidate);
43
+ break;
44
+ }
45
+ }
46
+ }
47
+ return paths;
48
+ }
49
+
50
+ function resolveCall(
51
+ tool: string,
52
+ args: unknown,
53
+ cwd: string,
54
+ ): { filePaths: string[] } | null {
55
+ const abs = (p: string) => (isAbsolute(p) ? p : join(cwd, p));
56
+
57
+ if (tool === "apply_patch") {
58
+ return { filePaths: pickPatchPaths(args).map(abs) };
59
+ }
60
+
61
+ const toolName = TOOL_MAP[tool];
62
+ if (!toolName) return null;
63
+
64
+ const raw = pickFilePath(args);
65
+ return { filePaths: raw ? [abs(raw)] : [] };
66
+ }
67
+
68
+ export const BridgePlugin: Plugin = async ({ directory }) => {
69
+ const cwd = directory ?? process.cwd();
70
+ const pendingByCallID = new Map<string, { filePaths: string[] }>();
71
+
72
+ return {
73
+ "tool.execute.before": async (input, output) => {
74
+ const call = resolveCall(input.tool, output.args, cwd);
75
+ if (!call || call.filePaths.length === 0) return;
76
+
77
+ for (const filePath of call.filePaths) {
78
+ const status = await checkBuffer(filePath);
79
+ if (status.hasUnsavedChanges && status.isCurrent) {
80
+ throw new Error(
81
+ "bridge: file has unsaved changes in Neovim",
82
+ );
83
+ }
84
+ }
85
+
86
+ pendingByCallID.set(input.callID, call);
87
+ },
88
+
89
+ "tool.execute.after": async (input) => {
90
+ const pending = pendingByCallID.get(input.callID);
91
+ if (!pending) return;
92
+ pendingByCallID.delete(input.callID);
93
+
94
+ for (const filePath of pending.filePaths) {
95
+ await refreshBuffer(filePath);
96
+ }
97
+ },
98
+
99
+ "chat.message": async (_input, output) => {
100
+ let selections;
101
+ try {
102
+ selections = await getVisualSelections();
103
+ } catch {
104
+ return;
105
+ }
106
+ if (!selections || selections.length === 0) return;
107
+
108
+ const filteredSelections = selections.filter(
109
+ (s) => !s.cwd || s.cwd === cwd,
110
+ );
111
+ if (filteredSelections.length === 0) return;
112
+
113
+ const textPart = output.parts.find((p: any) => p.type === "text") as any;
114
+ if (!textPart || typeof textPart.text !== "string") return;
115
+
116
+ const lines = filteredSelections.map((s) => {
117
+ const path = s.filePath.startsWith(cwd + "/")
118
+ ? "@" + s.filePath.slice(cwd.length + 1)
119
+ : s.filePath;
120
+ return `${path}:${s.startLine}-${s.endLine}`;
121
+ });
122
+
123
+ textPart.text = `${lines.join("\n")}\n\n${textPart.text}`;
124
+ },
125
+ };
126
+ };
127
+
128
+ export default BridgePlugin;
package/build.rs ADDED
@@ -0,0 +1,5 @@
1
+ extern crate napi_build;
2
+
3
+ fn main() {
4
+ napi_build::setup();
5
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@xinleibird/bridge-opencode",
3
+ "version": "0.0.1",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "type": "module",
8
+ "main": "bridge.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./bridge.ts",
12
+ "default": "./bridge.ts"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "napi build --platform --release",
17
+ "postinstall": "npm run build"
18
+ },
19
+ "napi": {
20
+ "name": "bridge-opencode",
21
+ "triples": {
22
+ "defaults": false,
23
+ "additional": [
24
+ "aarch64-apple-darwin",
25
+ "x86_64-apple-darwin"
26
+ ]
27
+ }
28
+ },
29
+ "dependencies": {
30
+ "@napi-rs/cli": "^2.18.0"
31
+ },
32
+ "peerDependencies": {
33
+ "@opencode-ai/plugin": ">=1.0.0"
34
+ }
35
+ }
@@ -0,0 +1,90 @@
1
+ use super::lua;
2
+ use crate::action::{BufferStatus, EditorContext};
3
+ use anyhow::{Context, Result};
4
+ use neovim_lib::{Neovim, NeovimApi, neovim_api::Buffer};
5
+ use std::path::PathBuf;
6
+
7
+ pub fn find_buffer(nvim: &mut Neovim, file_path: &str) -> Result<Buffer> {
8
+ let buffers = nvim.list_bufs().context("couldn't list buffers")?;
9
+
10
+ let target_path = PathBuf::from(file_path)
11
+ .canonicalize()
12
+ .unwrap_or_else(|_| PathBuf::from(file_path));
13
+
14
+ for buffer in buffers {
15
+ let buf_name = buffer.get_name(nvim).context("couldn't read buffer name")?;
16
+
17
+ if buf_name.is_empty() {
18
+ continue;
19
+ }
20
+
21
+ let buf_path = PathBuf::from(&buf_name)
22
+ .canonicalize()
23
+ .unwrap_or_else(|_| PathBuf::from(&buf_name));
24
+
25
+ if buf_path == target_path {
26
+ return Ok(buffer);
27
+ }
28
+ }
29
+
30
+ anyhow::bail!("file not open in Neovim: {}", file_path)
31
+ }
32
+
33
+ pub fn get_buffer_status(nvim: &mut Neovim, file_path: &str) -> Result<BufferStatus> {
34
+ let buffer = find_buffer(nvim, file_path)?;
35
+ let current_buf = nvim.get_current_buf()?;
36
+ let is_current = buffer == current_buf;
37
+
38
+ let modified = buffer.get_option(nvim, "modified")?;
39
+ let has_unsaved_changes = modified.as_bool().unwrap_or(false);
40
+
41
+ Ok(BufferStatus {
42
+ is_current,
43
+ has_unsaved_changes,
44
+ })
45
+ }
46
+
47
+ pub fn refresh_buffer(nvim: &mut Neovim, file_path: &str) -> Result<()> {
48
+ let buffer = find_buffer(nvim, file_path)?;
49
+ let buf_number = buffer.get_number(nvim)?;
50
+
51
+ let lua_code = lua::refresh_buffer_lua(buf_number);
52
+
53
+ nvim.execute_lua(&lua_code, vec![])
54
+ .map(|_| ())
55
+ .context("couldn't reload buffer")
56
+ }
57
+
58
+ pub fn get_visual_selection(nvim: &mut Neovim) -> Result<Option<EditorContext>> {
59
+ let lua_code = lua::get_visual_selection_lua();
60
+
61
+ let result = nvim
62
+ .execute_lua(lua_code, vec![])
63
+ .context("couldn't read visual selection")?;
64
+
65
+ if result.is_nil() {
66
+ return Ok(None);
67
+ }
68
+
69
+ let json_str = result.as_str().context("unexpected response from Neovim")?;
70
+
71
+ #[derive(serde::Deserialize)]
72
+ struct SelectionData {
73
+ file_path: String,
74
+ start_line: u32,
75
+ end_line: u32,
76
+ cwd: String,
77
+ content: String,
78
+ }
79
+
80
+ let data: SelectionData =
81
+ serde_json::from_str(json_str).context("couldn't parse visual selection")?;
82
+
83
+ Ok(Some(EditorContext {
84
+ file_path: data.file_path,
85
+ start_line: data.start_line,
86
+ end_line: data.end_line,
87
+ cwd: data.cwd,
88
+ content: data.content,
89
+ }))
90
+ }
@@ -0,0 +1,53 @@
1
+ use crate::constants::NEOVIM_RPC_TIMEOUT;
2
+ use anyhow::{Context, Result};
3
+ use neovim_lib::{Neovim, Session};
4
+ use std::path::PathBuf;
5
+
6
+ pub fn connect(socket_path: &PathBuf) -> Result<Neovim> {
7
+ let mut session =
8
+ Session::new_unix_socket(socket_path).context("couldn't connect to Neovim")?;
9
+ session.set_timeout(NEOVIM_RPC_TIMEOUT);
10
+ session.start_event_loop();
11
+ Ok(Neovim::new(session))
12
+ }
13
+
14
+ pub fn for_each_instance<F>(socket_paths: &[PathBuf], mut f: F) -> bool
15
+ where
16
+ F: FnMut(&mut Neovim) -> Result<()>,
17
+ {
18
+ socket_paths
19
+ .iter()
20
+ .filter_map(|path| connect(path).ok())
21
+ .any(|mut nvim| f(&mut nvim).is_ok())
22
+ }
23
+
24
+ pub fn try_fold_instances<T, F>(socket_paths: &[PathBuf], init: T, mut f: F) -> Option<T>
25
+ where
26
+ F: FnMut(&mut T, &mut Neovim) -> Result<bool>,
27
+ {
28
+ let mut any_processed = false;
29
+
30
+ let result = socket_paths
31
+ .iter()
32
+ .filter_map(|path| connect(path).ok())
33
+ .try_fold(init, |mut acc, mut nvim| match f(&mut acc, &mut nvim) {
34
+ Ok(should_continue) => {
35
+ any_processed = true;
36
+ if should_continue { Ok(acc) } else { Err(acc) }
37
+ }
38
+ Err(_) => Ok(acc),
39
+ });
40
+
41
+ any_processed.then(|| result.unwrap_or_else(|acc| acc))
42
+ }
43
+
44
+ pub fn collect_all<T, F>(socket_paths: &[PathBuf], mut f: F) -> Vec<T>
45
+ where
46
+ F: FnMut(&mut Neovim) -> Result<Option<T>>,
47
+ {
48
+ socket_paths
49
+ .iter()
50
+ .filter_map(|path| connect(path).ok())
51
+ .filter_map(|mut nvim| f(&mut nvim).ok().flatten())
52
+ .collect()
53
+ }
@@ -0,0 +1,74 @@
1
+ pub fn refresh_buffer_lua(buf_number: i64) -> String {
2
+ format!(
3
+ r#"
4
+ local buf = {}
5
+ local cursor_positions = {{}}
6
+ local is_current_buf = vim.api.nvim_get_current_buf() == buf
7
+
8
+ for _, win in ipairs(vim.api.nvim_list_wins()) do
9
+ if vim.api.nvim_win_get_buf(win) == buf then
10
+ cursor_positions[win] = vim.api.nvim_win_get_cursor(win)
11
+ end
12
+ end
13
+
14
+ vim.api.nvim_buf_call(buf, function()
15
+ vim.cmd('checktime')
16
+ vim.cmd('edit')
17
+ end)
18
+
19
+ for win, pos in pairs(cursor_positions) do
20
+ if vim.api.nvim_win_is_valid(win) then
21
+ pcall(vim.api.nvim_win_set_cursor, win, pos)
22
+ end
23
+ end
24
+
25
+ if is_current_buf then
26
+ vim.cmd('redraw')
27
+ end
28
+ "#,
29
+ buf_number
30
+ )
31
+ }
32
+
33
+ pub fn send_notification_lua(message: &str) -> String {
34
+ format!(
35
+ r#"vim.notify("{}", vim.log.levels.WARN)"#,
36
+ message.replace('"', r#"\""#)
37
+ )
38
+ }
39
+
40
+ pub fn get_visual_selection_lua() -> &'static str {
41
+ r#"
42
+ local mode = vim.fn.mode()
43
+ if not mode:match('[vV\22]') then
44
+ return nil
45
+ end
46
+
47
+ local start_pos = vim.fn.getpos("v")
48
+ local end_pos = vim.fn.getpos(".")
49
+ local sel_type = mode:sub(1, 1)
50
+
51
+ if start_pos[2] == 0 or end_pos[2] == 0 then
52
+ return nil
53
+ end
54
+
55
+ local file_path = vim.api.nvim_buf_get_name(0)
56
+ if file_path == "" then
57
+ return nil
58
+ end
59
+
60
+ local lines = vim.fn.getregion(start_pos, end_pos, { type = sel_type })
61
+ local content = table.concat(lines, "\n")
62
+
63
+ local start_line = math.min(start_pos[2], end_pos[2])
64
+ local end_line = math.max(start_pos[2], end_pos[2])
65
+
66
+ return vim.fn.json_encode({
67
+ file_path = file_path,
68
+ start_line = start_line,
69
+ end_line = end_line,
70
+ content = content,
71
+ cwd = vim.fn.getcwd()
72
+ })
73
+ "#
74
+ }
@@ -0,0 +1,74 @@
1
+ mod buffer;
2
+ mod connection;
3
+ mod lua;
4
+
5
+ use crate::action::{Action, BufferStatus, EditorContext};
6
+ use anyhow::Result;
7
+ use neovim_lib::NeovimApi;
8
+ use std::path::PathBuf;
9
+
10
+ pub struct NeovimAction {
11
+ socket_paths: Vec<PathBuf>,
12
+ }
13
+
14
+ impl NeovimAction {
15
+ pub fn new(socket_paths: Vec<PathBuf>) -> Self {
16
+ Self { socket_paths }
17
+ }
18
+ }
19
+
20
+ impl Action for NeovimAction {
21
+ fn buffer_status(&self, file_path: &str) -> Result<BufferStatus> {
22
+ let status = connection::try_fold_instances(
23
+ &self.socket_paths,
24
+ (false, false),
25
+ |(is_current_acc, unsaved_acc), nvim| {
26
+ let status = buffer::get_buffer_status(nvim, file_path)?;
27
+
28
+ *is_current_acc = *is_current_acc || status.is_current;
29
+ *unsaved_acc = *unsaved_acc || status.has_unsaved_changes;
30
+
31
+ Ok(!*unsaved_acc)
32
+ },
33
+ )
34
+ .unwrap_or((false, false));
35
+
36
+ Ok(BufferStatus {
37
+ is_current: status.0,
38
+ has_unsaved_changes: status.1,
39
+ })
40
+ }
41
+
42
+ fn refresh_buffer(&self, file_path: &str) -> Result<()> {
43
+ let any_success = connection::for_each_instance(&self.socket_paths, |nvim| {
44
+ buffer::refresh_buffer(nvim, file_path)
45
+ });
46
+
47
+ if any_success {
48
+ Ok(())
49
+ } else {
50
+ anyhow::bail!("couldn't refresh Neovim")
51
+ }
52
+ }
53
+
54
+ fn send_message(&self, message: &str) -> Result<()> {
55
+ let lua_code = lua::send_notification_lua(message);
56
+ let any_success = connection::for_each_instance(&self.socket_paths, |nvim| {
57
+ nvim.execute_lua(&lua_code, vec![])
58
+ .map(|_| ())
59
+ .map_err(|e| anyhow::anyhow!("couldn't send to Neovim: {}", e))
60
+ });
61
+
62
+ if any_success {
63
+ Ok(())
64
+ } else {
65
+ anyhow::bail!("couldn't send to Neovim")
66
+ }
67
+ }
68
+
69
+ fn get_visual_selections(&self) -> Result<Vec<EditorContext>> {
70
+ Ok(connection::collect_all(&self.socket_paths, |nvim| {
71
+ buffer::get_visual_selection(nvim)
72
+ }))
73
+ }
74
+ }
package/src/action.rs ADDED
@@ -0,0 +1,23 @@
1
+ pub mod neovim;
2
+
3
+ #[derive(Debug, Clone)]
4
+ pub struct BufferStatus {
5
+ pub is_current: bool,
6
+ pub has_unsaved_changes: bool,
7
+ }
8
+
9
+ #[derive(Debug, Clone)]
10
+ pub struct EditorContext {
11
+ pub file_path: String,
12
+ pub start_line: u32,
13
+ pub end_line: u32,
14
+ pub cwd: String,
15
+ pub content: String,
16
+ }
17
+
18
+ pub trait Action {
19
+ fn buffer_status(&self, file_path: &str) -> anyhow::Result<BufferStatus>;
20
+ fn refresh_buffer(&self, file_path: &str) -> anyhow::Result<()>;
21
+ fn send_message(&self, message: &str) -> anyhow::Result<()>;
22
+ fn get_visual_selections(&self) -> anyhow::Result<Vec<EditorContext>>;
23
+ }
@@ -0,0 +1,3 @@
1
+ use std::time::Duration;
2
+
3
+ pub const NEOVIM_RPC_TIMEOUT: Duration = Duration::from_secs(2);
package/src/handler.rs ADDED
@@ -0,0 +1,26 @@
1
+ use crate::action::{BufferStatus, EditorContext, Action, neovim::NeovimAction};
2
+ use crate::utils;
3
+
4
+ pub fn connect() -> anyhow::Result<NeovimAction> {
5
+ let socket_paths = utils::find_matching_sockets()?;
6
+ if socket_paths.is_empty() {
7
+ anyhow::bail!("no Neovim instances found");
8
+ }
9
+ Ok(NeovimAction::new(socket_paths))
10
+ }
11
+
12
+ pub fn check_buffer(action: &NeovimAction, file_path: &str) -> anyhow::Result<BufferStatus> {
13
+ action.buffer_status(file_path)
14
+ }
15
+
16
+ pub fn refresh_buffer(action: &NeovimAction, file_path: &str) -> anyhow::Result<()> {
17
+ action.refresh_buffer(file_path)
18
+ }
19
+
20
+ pub fn get_visual_selections(action: &NeovimAction) -> anyhow::Result<Vec<EditorContext>> {
21
+ action.get_visual_selections()
22
+ }
23
+
24
+ pub fn send_message(action: &NeovimAction, message: &str) -> anyhow::Result<()> {
25
+ action.send_message(message)
26
+ }
package/src/lib.rs ADDED
@@ -0,0 +1,88 @@
1
+ pub mod action;
2
+ pub mod constants;
3
+ pub mod handler;
4
+ pub mod utils;
5
+
6
+ #[cfg(feature = "napi")]
7
+ mod bindings {
8
+ use crate::action::neovim::NeovimAction;
9
+ use crate::action::EditorContext as InternalEditorContext;
10
+ use crate::handler;
11
+ use napi::bindgen_prelude::*;
12
+ use napi_derive::napi;
13
+
14
+ fn connect_nvim() -> Result<NeovimAction> {
15
+ handler::connect().map_err(|e| Error::from_reason(e.to_string()))
16
+ }
17
+
18
+ fn with_action<F, T>(f: F) -> Result<T>
19
+ where
20
+ F: FnOnce(&NeovimAction) -> anyhow::Result<T>,
21
+ {
22
+ let action = connect_nvim()?;
23
+ f(&action).map_err(|e| Error::from_reason(e.to_string()))
24
+ }
25
+
26
+ #[napi(object)]
27
+ #[derive(Clone)]
28
+ pub struct BufferStatus {
29
+ pub is_current: bool,
30
+ pub has_unsaved_changes: bool,
31
+ }
32
+
33
+ impl From<crate::action::BufferStatus> for BufferStatus {
34
+ fn from(s: crate::action::BufferStatus) -> Self {
35
+ Self {
36
+ is_current: s.is_current,
37
+ has_unsaved_changes: s.has_unsaved_changes,
38
+ }
39
+ }
40
+ }
41
+
42
+ #[napi(object)]
43
+ #[derive(Clone)]
44
+ pub struct EditorContext {
45
+ pub file_path: String,
46
+ pub start_line: u32,
47
+ pub end_line: u32,
48
+ pub cwd: String,
49
+ pub content: String,
50
+ }
51
+
52
+ impl From<InternalEditorContext> for EditorContext {
53
+ fn from(ctx: InternalEditorContext) -> Self {
54
+ Self {
55
+ file_path: ctx.file_path,
56
+ start_line: ctx.start_line,
57
+ end_line: ctx.end_line,
58
+ cwd: ctx.cwd,
59
+ content: ctx.content,
60
+ }
61
+ }
62
+ }
63
+
64
+ #[napi]
65
+ pub fn check_buffer(file_path: String) -> Result<BufferStatus> {
66
+ with_action(|action| {
67
+ handler::check_buffer(action, &file_path).map(BufferStatus::from)
68
+ })
69
+ }
70
+
71
+ #[napi]
72
+ pub fn refresh_buffer(file_path: String) -> Result<()> {
73
+ with_action(|action| handler::refresh_buffer(action, &file_path))
74
+ }
75
+
76
+ #[napi]
77
+ pub fn get_visual_selections() -> Result<Vec<EditorContext>> {
78
+ with_action(|action| {
79
+ handler::get_visual_selections(action)
80
+ .map(|v| v.into_iter().map(EditorContext::from).collect())
81
+ })
82
+ }
83
+
84
+ #[napi]
85
+ pub fn send_message(message: String) -> Result<()> {
86
+ with_action(|action| handler::send_message(action, &message))
87
+ }
88
+ }
package/src/utils.rs ADDED
@@ -0,0 +1,11 @@
1
+ use anyhow::Context;
2
+ use std::path::PathBuf;
3
+
4
+ pub fn find_matching_sockets() -> anyhow::Result<Vec<PathBuf>> {
5
+ let pattern = "/tmp/bridge-*.sock";
6
+ Ok(glob::glob(pattern)
7
+ .context("couldn't search for Neovim sockets")?
8
+ .filter_map(Result::ok)
9
+ .filter(|path| path.exists())
10
+ .collect())
11
+ }