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.
- package/LICENSE +203 -0
- package/NOTICE +4 -0
- package/README.md +134 -0
- package/bin/opencode-sidebar.js +93 -0
- package/dist/app.js +908 -0
- package/dist/index.js +30 -0
- package/dist/lib/constants.js +17 -0
- package/dist/lib/model.js +93 -0
- package/dist/lib/notifications.js +250 -0
- package/dist/lib/opencode.js +366 -0
- package/dist/lib/state.js +47 -0
- package/dist/lib/terminal.js +106 -0
- package/dist/lib/tmux.js +371 -0
- package/dist/lib/types.js +1 -0
- package/dist/lib/util.js +135 -0
- package/package.json +67 -0
- package/scripts/system-dependencies.mjs +42 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { App } from "./app.js";
|
|
5
|
+
import { LauncherService } from "./lib/opencode.js";
|
|
6
|
+
const service = new LauncherService();
|
|
7
|
+
let cleanedUp = false;
|
|
8
|
+
function renderApp() {
|
|
9
|
+
return _jsx(App, { service: service, onCleanup: cleanup });
|
|
10
|
+
}
|
|
11
|
+
async function cleanup() {
|
|
12
|
+
if (cleanedUp)
|
|
13
|
+
return;
|
|
14
|
+
cleanedUp = true;
|
|
15
|
+
await service.shutdown().catch(() => { });
|
|
16
|
+
}
|
|
17
|
+
process.on("SIGINT", () => {
|
|
18
|
+
void cleanup().finally(() => process.exit(0));
|
|
19
|
+
});
|
|
20
|
+
process.on("SIGTERM", () => {
|
|
21
|
+
void cleanup().finally(() => process.exit(0));
|
|
22
|
+
});
|
|
23
|
+
process.on("exit", () => {
|
|
24
|
+
void cleanup();
|
|
25
|
+
});
|
|
26
|
+
const instance = render(renderApp(), { exitOnCtrlC: false });
|
|
27
|
+
process.stdout.on("resize", () => {
|
|
28
|
+
instance.clear();
|
|
29
|
+
instance.rerender(renderApp());
|
|
30
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export const APP_NAME = "opencode-sidebar";
|
|
4
|
+
export const APP_DIR = process.env.OPENCODE_SIDEBAR_DIR ?? path.join(os.homedir(), ".local", "share", APP_NAME);
|
|
5
|
+
export const STATE_FILE = path.join(APP_DIR, "state.json");
|
|
6
|
+
export const SERVER_LOG_FILE = path.join(APP_DIR, "opencode-server.log");
|
|
7
|
+
export const PRIVATE_DIRECTORY_MODE = 0o700;
|
|
8
|
+
export const PRIVATE_FILE_MODE = 0o600;
|
|
9
|
+
export const DEFAULT_PORT = 42112;
|
|
10
|
+
export const SESSION_WINDOW_PREFIX = "opencode-session-";
|
|
11
|
+
export const LAUNCHER_WORKSPACE = "opencode-sidebar-launcher";
|
|
12
|
+
export const SERVER_HOST = "127.0.0.1";
|
|
13
|
+
export const SESSION_PAGE_LIMIT = 500;
|
|
14
|
+
export const WINDOW_POLL_INTERVAL_MS = 2000;
|
|
15
|
+
export const SNAPSHOT_DEBOUNCE_MS = 150;
|
|
16
|
+
export const STATUS_MESSAGE_HOLD_MS = 2500;
|
|
17
|
+
export const MAX_BACKGROUND_SESSIONS = 6;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { directoryLabel, directorySubtitle } from "./util.js";
|
|
2
|
+
function openSessionSet(panes) {
|
|
3
|
+
const result = new Set();
|
|
4
|
+
for (const pane of panes) {
|
|
5
|
+
if (!pane.workspace.startsWith("opencode-session-"))
|
|
6
|
+
continue;
|
|
7
|
+
result.add(pane.workspace.slice("opencode-session-".length));
|
|
8
|
+
}
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
function buildProjectMaps(projects) {
|
|
12
|
+
const byDirectory = new Map();
|
|
13
|
+
for (const project of projects) {
|
|
14
|
+
byDirectory.set(project.worktree, {
|
|
15
|
+
label: project.name,
|
|
16
|
+
root: project.worktree,
|
|
17
|
+
});
|
|
18
|
+
for (const sandbox of project.sandboxes) {
|
|
19
|
+
byDirectory.set(sandbox, {
|
|
20
|
+
root: project.worktree,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return byDirectory;
|
|
25
|
+
}
|
|
26
|
+
export function buildSnapshot(input) {
|
|
27
|
+
const projectMap = buildProjectMaps(input.projects);
|
|
28
|
+
const openedSessions = openSessionSet(input.panes);
|
|
29
|
+
const directoryMap = new Map();
|
|
30
|
+
const ensureDirectory = (directory) => {
|
|
31
|
+
let current = directoryMap.get(directory);
|
|
32
|
+
if (current)
|
|
33
|
+
return current;
|
|
34
|
+
const projectInfo = projectMap.get(directory);
|
|
35
|
+
current = {
|
|
36
|
+
directory,
|
|
37
|
+
label: directoryLabel(directory, projectInfo?.label),
|
|
38
|
+
subtitle: directorySubtitle(directory, projectInfo?.root),
|
|
39
|
+
pinned: input.pinnedDirectories.includes(directory),
|
|
40
|
+
sessions: [],
|
|
41
|
+
openSessionIDs: new Set(),
|
|
42
|
+
activeSessionIDs: new Set(),
|
|
43
|
+
};
|
|
44
|
+
directoryMap.set(directory, current);
|
|
45
|
+
return current;
|
|
46
|
+
};
|
|
47
|
+
for (const project of input.projects) {
|
|
48
|
+
ensureDirectory(project.worktree);
|
|
49
|
+
for (const sandbox of project.sandboxes)
|
|
50
|
+
ensureDirectory(sandbox);
|
|
51
|
+
}
|
|
52
|
+
for (const directory of input.pinnedDirectories) {
|
|
53
|
+
ensureDirectory(directory).pinned = true;
|
|
54
|
+
}
|
|
55
|
+
for (const session of input.sessions) {
|
|
56
|
+
const record = ensureDirectory(session.directory);
|
|
57
|
+
record.sessions.push(session);
|
|
58
|
+
record.lastUpdated = Math.max(record.lastUpdated ?? 0, session.time.updated);
|
|
59
|
+
if (openedSessions.has(session.id)) {
|
|
60
|
+
record.openSessionIDs.add(session.id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
for (const activeSession of input.activeSessions ?? []) {
|
|
64
|
+
const record = ensureDirectory(activeSession.directory);
|
|
65
|
+
record.activeSessionIDs.add(activeSession.sessionID);
|
|
66
|
+
record.openSessionIDs.add(activeSession.sessionID);
|
|
67
|
+
}
|
|
68
|
+
const pinnedRank = new Map(input.pinnedDirectories.map((directory, index) => [directory, index]));
|
|
69
|
+
const directories = [...directoryMap.values()]
|
|
70
|
+
.map((record) => ({
|
|
71
|
+
...record,
|
|
72
|
+
sessions: [...record.sessions].sort((a, b) => b.time.updated - a.time.updated),
|
|
73
|
+
}))
|
|
74
|
+
.sort((a, b) => {
|
|
75
|
+
if (a.pinned !== b.pinned)
|
|
76
|
+
return a.pinned ? -1 : 1;
|
|
77
|
+
if (a.pinned && b.pinned)
|
|
78
|
+
return (pinnedRank.get(a.directory) ?? 0) - (pinnedRank.get(b.directory) ?? 0);
|
|
79
|
+
const recentA = a.lastUpdated ?? 0;
|
|
80
|
+
const recentB = b.lastUpdated ?? 0;
|
|
81
|
+
if (recentA !== recentB)
|
|
82
|
+
return recentB - recentA;
|
|
83
|
+
return a.label.localeCompare(b.label);
|
|
84
|
+
});
|
|
85
|
+
return {
|
|
86
|
+
directories,
|
|
87
|
+
activeSessions: input.activeSessions ?? [],
|
|
88
|
+
previewSessionID: input.previewSessionID,
|
|
89
|
+
serverPort: input.serverPort,
|
|
90
|
+
baseUrl: input.baseUrl,
|
|
91
|
+
loadedAt: Date.now(),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const MACOS_SOUND_DIR = "/System/Library/Sounds";
|
|
5
|
+
const DEFAULT_ATTENTION_SOUND = "Glass";
|
|
6
|
+
const DEFAULT_COMPLETION_SOUND = "Ping";
|
|
7
|
+
const COMPLETION_SUPPRESSION_WINDOW_MS = 4_000;
|
|
8
|
+
function sessionIsWorking(status) {
|
|
9
|
+
return status?.type === "busy" || status?.type === "retry";
|
|
10
|
+
}
|
|
11
|
+
function resolveConfiguredSoundPath(value) {
|
|
12
|
+
const trimmed = value.trim();
|
|
13
|
+
if (!trimmed)
|
|
14
|
+
return undefined;
|
|
15
|
+
const expanded = trimmed.startsWith("~/") ? path.join(process.env.HOME ?? "", trimmed.slice(2)) : trimmed;
|
|
16
|
+
if (expanded.includes("/") || expanded.startsWith(".")) {
|
|
17
|
+
return path.resolve(expanded);
|
|
18
|
+
}
|
|
19
|
+
const fileName = /\.(aiff|wav|caf|mp3|m4a)$/i.test(expanded) ? expanded : `${expanded}.aiff`;
|
|
20
|
+
return path.join(MACOS_SOUND_DIR, fileName);
|
|
21
|
+
}
|
|
22
|
+
function playTerminalBell() {
|
|
23
|
+
try {
|
|
24
|
+
process.stdout.write("\u0007");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Ignore notifier fallback errors.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function notificationAudioMode() {
|
|
31
|
+
if (process.env.OPENCODE_SIDEBAR_NOTIFY === "0")
|
|
32
|
+
return "off";
|
|
33
|
+
if (process.platform === "darwin")
|
|
34
|
+
return "afplay";
|
|
35
|
+
return "bell";
|
|
36
|
+
}
|
|
37
|
+
export function playNotificationEffect(effect) {
|
|
38
|
+
const mode = notificationAudioMode();
|
|
39
|
+
if (mode === "off")
|
|
40
|
+
return;
|
|
41
|
+
if (mode === "bell") {
|
|
42
|
+
playTerminalBell();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const configuredSound = effect.kind === "attention"
|
|
46
|
+
? process.env.OPENCODE_SIDEBAR_NOTIFY_ATTENTION_SOUND ?? DEFAULT_ATTENTION_SOUND
|
|
47
|
+
: process.env.OPENCODE_SIDEBAR_NOTIFY_COMPLETE_SOUND ?? DEFAULT_COMPLETION_SOUND;
|
|
48
|
+
const soundPath = resolveConfiguredSoundPath(configuredSound);
|
|
49
|
+
if (!soundPath || !fs.existsSync(soundPath)) {
|
|
50
|
+
playTerminalBell();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const child = spawn("/usr/bin/afplay", [soundPath], {
|
|
54
|
+
stdio: "ignore",
|
|
55
|
+
});
|
|
56
|
+
child.once("error", () => {
|
|
57
|
+
playTerminalBell();
|
|
58
|
+
});
|
|
59
|
+
child.unref();
|
|
60
|
+
}
|
|
61
|
+
export class NotificationTracker {
|
|
62
|
+
now;
|
|
63
|
+
initialized = false;
|
|
64
|
+
busySessionIDs = new Set();
|
|
65
|
+
sessionByID = new Map();
|
|
66
|
+
seenQuestionRequestIDs = new Set();
|
|
67
|
+
seenPermissionRequestIDs = new Set();
|
|
68
|
+
lastCompletionAt = new Map();
|
|
69
|
+
constructor(now = () => Date.now()) {
|
|
70
|
+
this.now = now;
|
|
71
|
+
}
|
|
72
|
+
syncSnapshot(snapshot) {
|
|
73
|
+
const nextBusySessionIDs = new Set();
|
|
74
|
+
const nextSessionByID = new Map();
|
|
75
|
+
const effects = [];
|
|
76
|
+
for (const record of snapshot.directories) {
|
|
77
|
+
for (const session of record.sessions) {
|
|
78
|
+
nextSessionByID.set(session.id, {
|
|
79
|
+
sessionID: session.id,
|
|
80
|
+
title: session.title || "New session",
|
|
81
|
+
directory: record.directory,
|
|
82
|
+
updated: session.time.updated,
|
|
83
|
+
});
|
|
84
|
+
if (sessionIsWorking(session.status)) {
|
|
85
|
+
nextBusySessionIDs.add(session.id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (this.initialized) {
|
|
90
|
+
for (const sessionID of this.busySessionIDs) {
|
|
91
|
+
if (nextBusySessionIDs.has(sessionID))
|
|
92
|
+
continue;
|
|
93
|
+
const session = nextSessionByID.get(sessionID);
|
|
94
|
+
if (!session)
|
|
95
|
+
continue;
|
|
96
|
+
const effect = this.maybeEmitCompletion(session);
|
|
97
|
+
if (effect)
|
|
98
|
+
effects.push(effect);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
this.initialized = true;
|
|
103
|
+
}
|
|
104
|
+
for (const sessionID of this.lastCompletionAt.keys()) {
|
|
105
|
+
if (!nextSessionByID.has(sessionID)) {
|
|
106
|
+
this.lastCompletionAt.delete(sessionID);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
this.busySessionIDs = nextBusySessionIDs;
|
|
110
|
+
this.sessionByID = nextSessionByID;
|
|
111
|
+
return effects;
|
|
112
|
+
}
|
|
113
|
+
syncPendingRequests(input) {
|
|
114
|
+
const effects = [];
|
|
115
|
+
const nextQuestionRequestIDs = new Set();
|
|
116
|
+
const nextPermissionRequestIDs = new Set();
|
|
117
|
+
for (const request of input.questions) {
|
|
118
|
+
nextQuestionRequestIDs.add(request.id);
|
|
119
|
+
if (this.seenQuestionRequestIDs.has(request.id))
|
|
120
|
+
continue;
|
|
121
|
+
const session = this.sessionByID.get(request.sessionID);
|
|
122
|
+
effects.push(this.createAttentionEffect(`question:${request.id}`, session?.title || "OpenCode needs input", session?.directory || "Pending question"));
|
|
123
|
+
}
|
|
124
|
+
for (const request of input.permissions) {
|
|
125
|
+
nextPermissionRequestIDs.add(request.id);
|
|
126
|
+
if (this.seenPermissionRequestIDs.has(request.id))
|
|
127
|
+
continue;
|
|
128
|
+
const session = this.sessionByID.get(request.sessionID);
|
|
129
|
+
effects.push(this.createAttentionEffect(`permission:${request.id}`, session?.title || "OpenCode needs approval", session?.directory || "Pending permission"));
|
|
130
|
+
}
|
|
131
|
+
this.seenQuestionRequestIDs = nextQuestionRequestIDs;
|
|
132
|
+
this.seenPermissionRequestIDs = nextPermissionRequestIDs;
|
|
133
|
+
return effects;
|
|
134
|
+
}
|
|
135
|
+
handleEvent(input) {
|
|
136
|
+
const { directory, event } = input;
|
|
137
|
+
switch (event.type) {
|
|
138
|
+
case "session.created":
|
|
139
|
+
case "session.updated":
|
|
140
|
+
this.updateSessionMeta({
|
|
141
|
+
sessionID: event.properties.sessionID,
|
|
142
|
+
title: event.properties.info.title || "New session",
|
|
143
|
+
directory: event.properties.info.directory || directory,
|
|
144
|
+
updated: event.properties.info.time.updated,
|
|
145
|
+
});
|
|
146
|
+
return [];
|
|
147
|
+
case "session.deleted":
|
|
148
|
+
this.sessionByID.delete(event.properties.sessionID);
|
|
149
|
+
this.busySessionIDs.delete(event.properties.sessionID);
|
|
150
|
+
this.lastCompletionAt.delete(event.properties.sessionID);
|
|
151
|
+
return [];
|
|
152
|
+
case "question.asked": {
|
|
153
|
+
if (this.seenQuestionRequestIDs.has(event.properties.id))
|
|
154
|
+
return [];
|
|
155
|
+
this.seenQuestionRequestIDs.add(event.properties.id);
|
|
156
|
+
const session = this.sessionByID.get(event.properties.sessionID);
|
|
157
|
+
return [
|
|
158
|
+
this.createAttentionEffect(`question:${event.properties.id}`, session?.title || "OpenCode needs input", session?.directory || directory),
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
case "question.replied":
|
|
162
|
+
case "question.rejected":
|
|
163
|
+
this.seenQuestionRequestIDs.delete(event.properties.requestID);
|
|
164
|
+
return [];
|
|
165
|
+
case "permission.asked": {
|
|
166
|
+
if (this.seenPermissionRequestIDs.has(event.properties.id))
|
|
167
|
+
return [];
|
|
168
|
+
this.seenPermissionRequestIDs.add(event.properties.id);
|
|
169
|
+
const session = this.sessionByID.get(event.properties.sessionID);
|
|
170
|
+
return [
|
|
171
|
+
this.createAttentionEffect(`permission:${event.properties.id}`, session?.title || "OpenCode needs approval", session?.directory || directory),
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
case "permission.replied":
|
|
175
|
+
this.seenPermissionRequestIDs.delete(event.properties.requestID);
|
|
176
|
+
return [];
|
|
177
|
+
case "session.status":
|
|
178
|
+
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
|
|
179
|
+
this.busySessionIDs.add(event.properties.sessionID);
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
return this.completeFromEvent(event.properties.sessionID, directory);
|
|
183
|
+
case "session.idle":
|
|
184
|
+
return this.completeFromEvent(event.properties.sessionID, directory);
|
|
185
|
+
default:
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
completeFromEvent(sessionID, directory) {
|
|
190
|
+
if (!this.busySessionIDs.has(sessionID))
|
|
191
|
+
return [];
|
|
192
|
+
this.busySessionIDs.delete(sessionID);
|
|
193
|
+
const session = this.sessionByID.get(sessionID);
|
|
194
|
+
const effect = this.maybeEmitCompletion(session ?? {
|
|
195
|
+
sessionID,
|
|
196
|
+
title: "OpenCode finished processing",
|
|
197
|
+
directory,
|
|
198
|
+
updated: this.now(),
|
|
199
|
+
});
|
|
200
|
+
return effect ? [effect] : [];
|
|
201
|
+
}
|
|
202
|
+
createAttentionEffect(id, title, detail) {
|
|
203
|
+
return {
|
|
204
|
+
id,
|
|
205
|
+
kind: "attention",
|
|
206
|
+
title,
|
|
207
|
+
detail,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
updateSessionMeta(session) {
|
|
211
|
+
this.sessionByID.set(session.sessionID, session);
|
|
212
|
+
}
|
|
213
|
+
maybeEmitCompletion(session) {
|
|
214
|
+
const now = this.now();
|
|
215
|
+
const lastCompletion = this.lastCompletionAt.get(session.sessionID) ?? 0;
|
|
216
|
+
if (now - lastCompletion < COMPLETION_SUPPRESSION_WINDOW_MS) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
this.lastCompletionAt.set(session.sessionID, now);
|
|
220
|
+
return {
|
|
221
|
+
id: `completion:${session.sessionID}:${session.updated}`,
|
|
222
|
+
kind: "completion",
|
|
223
|
+
title: session.title || "OpenCode finished processing",
|
|
224
|
+
detail: session.directory,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
export class SoundNotifier {
|
|
229
|
+
dispatch;
|
|
230
|
+
tracker;
|
|
231
|
+
constructor(dispatch = playNotificationEffect) {
|
|
232
|
+
this.dispatch = dispatch;
|
|
233
|
+
this.tracker = new NotificationTracker();
|
|
234
|
+
}
|
|
235
|
+
syncSnapshot(snapshot) {
|
|
236
|
+
for (const effect of this.tracker.syncSnapshot(snapshot)) {
|
|
237
|
+
this.dispatch(effect);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
syncPendingRequests(input) {
|
|
241
|
+
for (const effect of this.tracker.syncPendingRequests(input)) {
|
|
242
|
+
this.dispatch(effect);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
handleEvent(input) {
|
|
246
|
+
for (const effect of this.tracker.handleEvent(input)) {
|
|
247
|
+
this.dispatch(effect);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|