opencode-sidebar 0.1.0

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.
@@ -0,0 +1,371 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { MAX_BACKGROUND_SESSIONS } from "./constants.js";
4
+ import { sessionWindowTitle, tmuxWindowName } from "./util.js";
5
+ const execFileAsync = promisify(execFile);
6
+ let cachedRightPaneID;
7
+ const SESSION_OPTION = "@opencode_session_id";
8
+ const DIRECTORY_OPTION = "@opencode_directory";
9
+ const TITLE_OPTION = "@opencode_title";
10
+ const PREVIEW_SESSION_OPTION = "@opencode_preview_session_id";
11
+ const PREVIEW_DIRECTORY_OPTION = "@opencode_preview_directory";
12
+ const PREVIEW_TITLE_OPTION = "@opencode_preview_title";
13
+ const PREVIEW_PANE_OPTION = "@opencode_preview_pane_id";
14
+ async function runTmux(args) {
15
+ const { stdout } = await execFileAsync("tmux", args, {
16
+ maxBuffer: 1024 * 1024,
17
+ });
18
+ return stdout.trim();
19
+ }
20
+ async function getPaneWidth(paneID) {
21
+ return Number(await runTmux([
22
+ "display-message",
23
+ "-p",
24
+ "-t",
25
+ paneID,
26
+ "#{pane_width}",
27
+ ]));
28
+ }
29
+ export function isTmux() {
30
+ return Boolean(process.env.TMUX && process.env.TMUX_PANE);
31
+ }
32
+ export async function getCurrentPaneID() {
33
+ if (!process.env.TMUX_PANE)
34
+ throw new Error("Not running inside tmux");
35
+ return process.env.TMUX_PANE;
36
+ }
37
+ export async function getSessionName() {
38
+ return runTmux(["display-message", "-p", "#{session_name}"]);
39
+ }
40
+ export async function listActiveSessionWindows() {
41
+ const session = await getSessionName();
42
+ const output = await runTmux([
43
+ "list-panes",
44
+ "-a",
45
+ "-t",
46
+ session,
47
+ "-F",
48
+ `#{pane_id}\t#{window_id}\t#{window_name}\t#{window_active}\t#{${SESSION_OPTION}}\t#{${DIRECTORY_OPTION}}\t#{${TITLE_OPTION}}`,
49
+ ]);
50
+ return output
51
+ .split("\n")
52
+ .filter(Boolean)
53
+ .map((line) => line.split("\t"))
54
+ .filter((parts) => parts[4])
55
+ .map(([paneID, windowID, windowName, active, sessionID, directory, title]) => ({
56
+ paneID,
57
+ windowID,
58
+ windowName,
59
+ active: active === "1",
60
+ sessionID,
61
+ directory,
62
+ title,
63
+ }));
64
+ }
65
+ export function trimBackgroundSessions(records, maxBackgroundSessions = MAX_BACKGROUND_SESSIONS) {
66
+ if (maxBackgroundSessions < 0)
67
+ return [];
68
+ return [...records]
69
+ .filter((record) => !record.active)
70
+ .sort((a, b) => {
71
+ const windowA = Number(a.windowID.replace(/^@/, ""));
72
+ const windowB = Number(b.windowID.replace(/^@/, ""));
73
+ return windowA - windowB;
74
+ })
75
+ .slice(0, Math.max(0, records.filter((record) => !record.active).length - maxBackgroundSessions));
76
+ }
77
+ export async function findWindowBySessionID(sessionID) {
78
+ const windows = await listActiveSessionWindows();
79
+ return windows.find((window) => window.sessionID === sessionID);
80
+ }
81
+ export async function renameSessionWindow(sessionID, directory, title) {
82
+ const preview = await getPreviewSessionMeta();
83
+ const target = await findAnyWindowBySessionID(sessionID);
84
+ if (preview?.sessionID === sessionID && preview.paneID) {
85
+ await setPreviewSession({
86
+ sessionID,
87
+ directory,
88
+ title,
89
+ paneID: preview.paneID,
90
+ });
91
+ await setPaneSession({
92
+ paneID: preview.paneID,
93
+ sessionID,
94
+ directory,
95
+ title,
96
+ });
97
+ await setPaneTitle(preview.paneID, sessionWindowTitle(directory, title));
98
+ return true;
99
+ }
100
+ if (!target)
101
+ return false;
102
+ await runTmux(["rename-window", "-t", target.windowID, tmuxWindowName(directory, title)]).catch(() => { });
103
+ await runTmux(["set-option", "-w", "-t", target.windowID, SESSION_OPTION, sessionID]).catch(() => { });
104
+ await runTmux(["set-option", "-w", "-t", target.windowID, DIRECTORY_OPTION, directory]).catch(() => { });
105
+ await runTmux(["set-option", "-w", "-t", target.windowID, TITLE_OPTION, title]).catch(() => { });
106
+ return true;
107
+ }
108
+ export async function findBackgroundWindowBySessionID(sessionID) {
109
+ const preview = await getPreviewSessionMeta();
110
+ const windows = await listActiveSessionWindows();
111
+ return windows.find((window) => {
112
+ if (window.sessionID !== sessionID)
113
+ return false;
114
+ if (preview?.paneID && window.paneID === preview.paneID)
115
+ return false;
116
+ return !window.active || Boolean(window.sessionID);
117
+ });
118
+ }
119
+ export async function findAnyWindowBySessionID(sessionID) {
120
+ const preview = await getPreviewSessionMeta();
121
+ const windows = await listActiveSessionWindows();
122
+ const previewWindow = preview?.paneID ? windows.find((window) => window.paneID === preview.paneID) : undefined;
123
+ if (previewWindow?.sessionID === sessionID) {
124
+ return {
125
+ ...previewWindow,
126
+ preview: true,
127
+ };
128
+ }
129
+ const parked = windows.find((window) => window.sessionID === sessionID);
130
+ if (!parked)
131
+ return undefined;
132
+ return {
133
+ ...parked,
134
+ preview: false,
135
+ };
136
+ }
137
+ export async function setPreviewSession(input) {
138
+ const session = await getSessionName();
139
+ await runTmux(["set-option", "-t", session, PREVIEW_SESSION_OPTION, input.sessionID]);
140
+ await runTmux(["set-option", "-t", session, PREVIEW_DIRECTORY_OPTION, input.directory]);
141
+ await runTmux(["set-option", "-t", session, PREVIEW_TITLE_OPTION, input.title]);
142
+ if (input.paneID) {
143
+ await runTmux(["set-option", "-t", session, PREVIEW_PANE_OPTION, input.paneID]);
144
+ }
145
+ }
146
+ export async function getPreviewSession() {
147
+ const session = await getSessionName();
148
+ const value = await runTmux(["show-options", "-v", "-t", session, PREVIEW_SESSION_OPTION]).catch(() => "");
149
+ return value || undefined;
150
+ }
151
+ export async function getPreviewSessionMeta() {
152
+ const sessionID = await getPreviewSession();
153
+ if (!sessionID)
154
+ return undefined;
155
+ const session = await getSessionName();
156
+ const directory = await runTmux(["show-options", "-v", "-t", session, PREVIEW_DIRECTORY_OPTION]).catch(() => "");
157
+ const title = await runTmux(["show-options", "-v", "-t", session, PREVIEW_TITLE_OPTION]).catch(() => "");
158
+ const paneID = await runTmux(["show-options", "-v", "-t", session, PREVIEW_PANE_OPTION]).catch(() => "");
159
+ return {
160
+ sessionID,
161
+ directory,
162
+ title,
163
+ paneID,
164
+ };
165
+ }
166
+ export async function clearPreviewSession() {
167
+ const session = await getSessionName();
168
+ await runTmux(["set-option", "-u", "-t", session, PREVIEW_SESSION_OPTION]).catch(() => { });
169
+ await runTmux(["set-option", "-u", "-t", session, PREVIEW_DIRECTORY_OPTION]).catch(() => { });
170
+ await runTmux(["set-option", "-u", "-t", session, PREVIEW_TITLE_OPTION]).catch(() => { });
171
+ await runTmux(["set-option", "-u", "-t", session, PREVIEW_PANE_OPTION]).catch(() => { });
172
+ }
173
+ export async function killSessionWindowBySessionID(sessionID) {
174
+ const preview = await getPreviewSessionMeta();
175
+ if (preview?.sessionID === sessionID && preview.paneID) {
176
+ await clearPreviewSession();
177
+ await killPane(preview.paneID).catch(() => { });
178
+ return true;
179
+ }
180
+ const target = await findAnyWindowBySessionID(sessionID);
181
+ if (!target)
182
+ return false;
183
+ if (target.preview) {
184
+ await clearPreviewSession();
185
+ }
186
+ await killWindow(target.windowID);
187
+ return true;
188
+ }
189
+ export async function getCurrentWindowID() {
190
+ return runTmux(["display-message", "-p", "#{window_id}"]);
191
+ }
192
+ export async function killWindow(windowID) {
193
+ await runTmux(["kill-window", "-t", windowID]);
194
+ }
195
+ export async function killPane(paneID) {
196
+ await runTmux(["kill-pane", "-t", paneID]);
197
+ }
198
+ export async function pruneBackgroundSessions(options) {
199
+ const keep = new Set(options?.keepSessionIDs ?? []);
200
+ const windows = await listActiveSessionWindows();
201
+ const victims = trimBackgroundSessions(windows.filter((window) => !keep.has(window.sessionID)), options?.maxBackgroundSessions);
202
+ for (const window of victims) {
203
+ await killWindow(window.windowID).catch(() => { });
204
+ }
205
+ return victims;
206
+ }
207
+ export async function clearWindowSession(windowID) {
208
+ await runTmux(["set-option", "-u", "-w", "-t", windowID, SESSION_OPTION]).catch(() => { });
209
+ await runTmux(["set-option", "-u", "-w", "-t", windowID, DIRECTORY_OPTION]).catch(() => { });
210
+ await runTmux(["set-option", "-u", "-w", "-t", windowID, TITLE_OPTION]).catch(() => { });
211
+ }
212
+ export async function setPaneSession(input) {
213
+ await runTmux(["set-option", "-p", "-t", input.paneID, SESSION_OPTION, input.sessionID]).catch(() => { });
214
+ await runTmux(["set-option", "-p", "-t", input.paneID, DIRECTORY_OPTION, input.directory]).catch(() => { });
215
+ await runTmux(["set-option", "-p", "-t", input.paneID, TITLE_OPTION, input.title]).catch(() => { });
216
+ }
217
+ export async function getRightPaneID(selectorPaneID) {
218
+ if (cachedRightPaneID) {
219
+ const all = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]).catch(() => "");
220
+ if (all.split("\n").includes(cachedRightPaneID)) {
221
+ return cachedRightPaneID;
222
+ }
223
+ cachedRightPaneID = undefined;
224
+ }
225
+ const pane = await runTmux([
226
+ "list-panes",
227
+ "-t",
228
+ selectorPaneID,
229
+ "-F",
230
+ "#{pane_id} #{pane_left}",
231
+ ]);
232
+ const selectorLeft = Number(await runTmux([
233
+ "display-message",
234
+ "-p",
235
+ "-t",
236
+ selectorPaneID,
237
+ "#{pane_left}",
238
+ ]));
239
+ let nearest;
240
+ for (const line of pane.split("\n")) {
241
+ const [paneID, left] = line.trim().split(" ");
242
+ if (!paneID || left === undefined)
243
+ continue;
244
+ if (paneID === selectorPaneID)
245
+ continue;
246
+ const numericLeft = Number(left);
247
+ if (numericLeft <= selectorLeft)
248
+ continue;
249
+ if (!nearest || numericLeft < nearest.left) {
250
+ nearest = { paneID, left: numericLeft };
251
+ }
252
+ }
253
+ if (nearest) {
254
+ cachedRightPaneID = nearest.paneID;
255
+ return nearest.paneID;
256
+ }
257
+ return undefined;
258
+ }
259
+ export async function splitRightPane(selectorPaneID, cwd) {
260
+ const totalWidth = await getPaneWidth(selectorPaneID);
261
+ const idealSidebarWidth = 52;
262
+ const minimumSidebarWidth = 36;
263
+ const minimumRightWidth = 44;
264
+ const sidebarWidth = Math.max(minimumSidebarWidth, Math.min(idealSidebarWidth, totalWidth - minimumRightWidth));
265
+ const rightWidth = Math.max(1, totalWidth - sidebarWidth);
266
+ const paneID = await runTmux([
267
+ "split-window",
268
+ "-h",
269
+ "-d",
270
+ "-t",
271
+ selectorPaneID,
272
+ "-l",
273
+ String(rightWidth),
274
+ "-c",
275
+ cwd,
276
+ "-P",
277
+ "-F",
278
+ "#{pane_id}",
279
+ "",
280
+ ]);
281
+ cachedRightPaneID = paneID;
282
+ return paneID;
283
+ }
284
+ export async function respawnPane(paneID, cwd, command) {
285
+ await runTmux([
286
+ "respawn-pane",
287
+ "-k",
288
+ "-t",
289
+ paneID,
290
+ "-c",
291
+ cwd,
292
+ ...command,
293
+ ]);
294
+ }
295
+ export async function selectPane(paneID) {
296
+ await runTmux(["select-pane", "-t", paneID]);
297
+ }
298
+ export async function setPaneTitle(paneID, title) {
299
+ await runTmux(["select-pane", "-t", paneID, "-T", title]);
300
+ }
301
+ export async function getPaneWindowID(paneID) {
302
+ return runTmux(["display-message", "-p", "-t", paneID, "#{window_id}"]);
303
+ }
304
+ export async function bindToggleKeys(selectorPaneID) {
305
+ const rightPaneID = await getRightPaneID(selectorPaneID);
306
+ if (!rightPaneID)
307
+ return;
308
+ const session = await getSessionName();
309
+ await runTmux(["bind-key", "-n", "M-]", "select-pane", "-t", rightPaneID]);
310
+ await runTmux(["bind-key", "-n", "M-b", "select-window", "-t", `${session}:0`]);
311
+ }
312
+ export async function ensureTmuxLayout(cwd) {
313
+ const selectorPaneID = await getCurrentPaneID();
314
+ let rightPaneID = await getRightPaneID(selectorPaneID);
315
+ if (!rightPaneID) {
316
+ rightPaneID = await splitRightPane(selectorPaneID, cwd);
317
+ }
318
+ await bindToggleKeys(selectorPaneID);
319
+ return {
320
+ selectorPaneID,
321
+ rightPaneID,
322
+ };
323
+ }
324
+ export async function swapPreviewWithSessionPane(input) {
325
+ await runTmux(["swap-pane", "-d", "-s", input.sessionPaneID, "-t", input.previewPaneID]);
326
+ cachedRightPaneID = input.sessionPaneID;
327
+ await setPreviewSession({
328
+ ...input.nextSession,
329
+ paneID: input.sessionPaneID,
330
+ });
331
+ await setPaneSession({
332
+ paneID: input.sessionPaneID,
333
+ sessionID: input.nextSession.sessionID,
334
+ directory: input.nextSession.directory,
335
+ title: input.nextSession.title,
336
+ });
337
+ const launcherWindowID = await getCurrentWindowID();
338
+ await clearWindowSession(launcherWindowID);
339
+ if (input.previewSession) {
340
+ await runTmux(["rename-window", "-t", input.hiddenWindowID, tmuxWindowName(input.previewSession.directory, input.previewSession.title)]).catch(() => { });
341
+ await runTmux(["set-option", "-w", "-t", input.hiddenWindowID, SESSION_OPTION, input.previewSession.sessionID]).catch(() => { });
342
+ await runTmux(["set-option", "-w", "-t", input.hiddenWindowID, DIRECTORY_OPTION, input.previewSession.directory]).catch(() => { });
343
+ await runTmux(["set-option", "-w", "-t", input.hiddenWindowID, TITLE_OPTION, input.previewSession.title]).catch(() => { });
344
+ }
345
+ else {
346
+ await killWindow(input.hiddenWindowID);
347
+ }
348
+ await setPaneTitle(input.sessionPaneID, sessionWindowTitle(input.nextSession.directory, input.nextSession.title));
349
+ }
350
+ export async function parkPreviewSession(input) {
351
+ const output = await runTmux([
352
+ "break-pane",
353
+ "-d",
354
+ "-s",
355
+ input.previewPaneID,
356
+ "-P",
357
+ "-F",
358
+ "#{window_id}\t#{pane_id}",
359
+ ]);
360
+ const [windowID, paneID] = output.split("\t");
361
+ cachedRightPaneID = undefined;
362
+ await runTmux(["rename-window", "-t", windowID, tmuxWindowName(input.directory, input.title)]).catch(() => { });
363
+ await runTmux(["set-option", "-w", "-t", windowID, SESSION_OPTION, input.sessionID]).catch(() => { });
364
+ await runTmux(["set-option", "-w", "-t", windowID, DIRECTORY_OPTION, input.directory]).catch(() => { });
365
+ await runTmux(["set-option", "-w", "-t", windowID, TITLE_OPTION, input.title]).catch(() => { });
366
+ await clearPreviewSession();
367
+ return {
368
+ windowID,
369
+ paneID,
370
+ };
371
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,135 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ export function truncate(input, width) {
5
+ if (width <= 0)
6
+ return "";
7
+ if (input.length <= width)
8
+ return input;
9
+ if (width <= 1)
10
+ return input.slice(0, width);
11
+ return input.slice(0, width - 1) + "…";
12
+ }
13
+ export function relativeTime(timestamp, now = Date.now()) {
14
+ const delta = Math.max(0, now - timestamp);
15
+ const minute = 60_000;
16
+ const hour = 60 * minute;
17
+ const day = 24 * hour;
18
+ const week = 7 * day;
19
+ const month = 30 * day;
20
+ if (delta < minute)
21
+ return "now";
22
+ if (delta < hour)
23
+ return `${Math.floor(delta / minute)}m`;
24
+ if (delta < day)
25
+ return `${Math.floor(delta / hour)}h`;
26
+ if (delta < week)
27
+ return `${Math.floor(delta / day)}d`;
28
+ if (delta < month)
29
+ return `${Math.floor(delta / week)}w`;
30
+ return `${Math.floor(delta / month)}mo`;
31
+ }
32
+ export function normalizeDirectory(input) {
33
+ const trimmed = input.trim();
34
+ if (!trimmed)
35
+ throw new Error("Directory path is empty");
36
+ const expanded = trimmed.startsWith("~/") ? path.join(process.env.HOME ?? "", trimmed.slice(2)) : trimmed;
37
+ return path.resolve(expanded);
38
+ }
39
+ export async function assertDirectoryExists(directory) {
40
+ const stats = await fs.stat(directory);
41
+ if (!stats.isDirectory()) {
42
+ throw new Error(`${directory} is not a directory`);
43
+ }
44
+ }
45
+ export function directoryLabel(directory, preferred) {
46
+ return preferred?.trim() || path.basename(directory) || directory;
47
+ }
48
+ export function directorySubtitle(directory, root) {
49
+ if (!root || root === directory)
50
+ return directory;
51
+ return `${directory} · ${path.basename(root)}`;
52
+ }
53
+ export function sessionWorkspace(sessionID) {
54
+ return `opencode-session-${sessionID}`;
55
+ }
56
+ export function sessionWindowTitle(directory, title) {
57
+ const base = path.basename(directory) || directory;
58
+ return `OpenCode · ${base} · ${truncate(title || "New session", 60)}`;
59
+ }
60
+ export function tmuxWindowName(directory, title) {
61
+ const base = path.basename(directory) || directory;
62
+ return truncate(`${base} · ${title || "New session"}`, 24);
63
+ }
64
+ export function parseFileUrl(input) {
65
+ if (!input)
66
+ return undefined;
67
+ if (!input.startsWith("file://"))
68
+ return input;
69
+ try {
70
+ return fileURLToPath(input);
71
+ }
72
+ catch {
73
+ return input;
74
+ }
75
+ }
76
+ export function distinct(items) {
77
+ return [...new Set(items)];
78
+ }
79
+ export function sleep(ms) {
80
+ return new Promise((resolve) => setTimeout(resolve, ms));
81
+ }
82
+ export function isPrintable(input) {
83
+ return input.length === 1 && input >= " " && input !== "\u007f";
84
+ }
85
+ export function sanitizePastedText(input) {
86
+ return input.replace(/[\r\n]+/g, "").replace(/\t+/g, " ");
87
+ }
88
+ export function wrapTextHard(input, width) {
89
+ if (width <= 0)
90
+ return [];
91
+ if (!input)
92
+ return [""];
93
+ const lines = [];
94
+ for (const paragraph of input.split(/\r?\n/)) {
95
+ if (!paragraph) {
96
+ lines.push("");
97
+ continue;
98
+ }
99
+ const words = paragraph.split(/\s+/).filter(Boolean);
100
+ let current = "";
101
+ const pushChunk = (chunk) => {
102
+ if (!chunk)
103
+ return;
104
+ lines.push(chunk);
105
+ };
106
+ for (const word of words) {
107
+ if (word.length > width) {
108
+ if (current) {
109
+ lines.push(current);
110
+ current = "";
111
+ }
112
+ for (let index = 0; index < word.length; index += width) {
113
+ pushChunk(word.slice(index, index + width));
114
+ }
115
+ continue;
116
+ }
117
+ if (!current) {
118
+ current = word;
119
+ continue;
120
+ }
121
+ const next = `${current} ${word}`;
122
+ if (next.length <= width) {
123
+ current = next;
124
+ }
125
+ else {
126
+ lines.push(current);
127
+ current = word;
128
+ }
129
+ }
130
+ if (current) {
131
+ lines.push(current);
132
+ }
133
+ }
134
+ return lines.length ? lines : [""];
135
+ }
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "opencode-sidebar",
3
+ "version": "0.1.0",
4
+ "description": "tmux sidebar launcher for OpenCode",
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "author": "Arnav Kumar (https://github.com/arnavpisces)",
8
+ "homepage": "https://github.com/arnavpisces/opencode-sidebar#readme",
9
+ "bugs": {
10
+ "url": "https://github.com/arnavpisces/opencode-sidebar/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/arnavpisces/opencode-sidebar.git"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "tmux",
19
+ "ink",
20
+ "cli"
21
+ ],
22
+ "bin": {
23
+ "opencode-sidebar": "bin/opencode-sidebar.js"
24
+ },
25
+ "files": [
26
+ "bin/opencode-sidebar.js",
27
+ "dist",
28
+ "scripts/system-dependencies.mjs",
29
+ "README.md",
30
+ "LICENSE",
31
+ "NOTICE"
32
+ ],
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && ./node_modules/.bin/tsc -p tsconfig.build.json",
41
+ "start": "npm run build && node ./bin/opencode-sidebar.js",
42
+ "dev": "bun --watch src/index.tsx",
43
+ "launcher": "./bin/opencode-sidebar-tmux",
44
+ "launcher:tmux": "./bin/opencode-sidebar-tmux",
45
+ "open:test": "bun run src/dev-open-session.ts",
46
+ "prepack": "npm run verify",
47
+ "test": "bun test",
48
+ "test:tmux:cleanup": "./test/run-tmux-cleanup.sh",
49
+ "test:tmux:flow": "./test/run-tmux-flow.sh",
50
+ "test:tmux:sigint-cleanup": "./test/run-sidebar-sigint-cleanup.sh",
51
+ "typecheck": "tsc --noEmit",
52
+ "verify": "npm run typecheck && npm run test && npm run build",
53
+ "verify:release": "npm run verify && npm run verify:tmux",
54
+ "verify:tmux": "npm run test:tmux:flow && npm run test:tmux:cleanup && npm run test:tmux:sigint-cleanup"
55
+ },
56
+ "dependencies": {
57
+ "@opencode-ai/sdk": "1.3.15",
58
+ "ink": "6.8.0",
59
+ "react": "19.2.0"
60
+ },
61
+ "devDependencies": {
62
+ "@types/node": "22.15.3",
63
+ "@types/react": "19.2.2",
64
+ "bun-types": "1.3.11",
65
+ "typescript": "5.8.3"
66
+ }
67
+ }
@@ -0,0 +1,42 @@
1
+ import { spawnSync } from "node:child_process"
2
+
3
+ const SYSTEM_DEPENDENCIES = [
4
+ {
5
+ command: "tmux",
6
+ probeArgs: ["-V"],
7
+ installHint: "Install tmux and make sure it is available on PATH.",
8
+ },
9
+ {
10
+ command: "opencode",
11
+ probeArgs: ["--version"],
12
+ installHint: "Install the OpenCode CLI and make sure it is available on PATH.",
13
+ },
14
+ ]
15
+
16
+ function dependencyExists(command, probeArgs) {
17
+ const result = spawnSync(command, probeArgs, {
18
+ stdio: "ignore",
19
+ })
20
+ return result.error?.code !== "ENOENT"
21
+ }
22
+
23
+ export function getSystemDependencyReport() {
24
+ const missing = SYSTEM_DEPENDENCIES.filter((dependency) => !dependencyExists(dependency.command, dependency.probeArgs))
25
+ return {
26
+ allFound: missing.length === 0,
27
+ missing,
28
+ }
29
+ }
30
+
31
+ export function formatMissingDependencyMessage(report) {
32
+ if (report.allFound) {
33
+ return "[opencode-sidebar] Found required system dependencies: tmux, opencode."
34
+ }
35
+
36
+ const details = report.missing.map((dependency) => `- ${dependency.command}: ${dependency.installHint}`).join("\n")
37
+ return [
38
+ "[opencode-sidebar] Installed, but some required system dependencies are missing.",
39
+ details,
40
+ "[opencode-sidebar] The package will not run until those commands are installed.",
41
+ ].join("\n")
42
+ }