clideck 1.22.2
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 +21 -0
- package/README.md +77 -0
- package/activity.js +56 -0
- package/agent-presets.json +93 -0
- package/assets/clideck-themes.jpg +0 -0
- package/bin/clideck.js +2 -0
- package/config.js +96 -0
- package/handlers.js +297 -0
- package/opencode-bridge.js +148 -0
- package/opencode-plugin/clideck-bridge.js +24 -0
- package/package.json +47 -0
- package/paths.js +41 -0
- package/plugin-loader.js +285 -0
- package/plugins/trim-clip/clideck-plugin.json +13 -0
- package/plugins/trim-clip/client.js +31 -0
- package/plugins/trim-clip/index.js +10 -0
- package/plugins/voice-input/clideck-plugin.json +49 -0
- package/plugins/voice-input/client.js +196 -0
- package/plugins/voice-input/index.js +342 -0
- package/plugins/voice-input/python/mel_filters.npz +0 -0
- package/plugins/voice-input/python/whisper_turbo.py +416 -0
- package/plugins/voice-input/python/worker.py +135 -0
- package/public/fx/bold-beep-idle.mp3 +0 -0
- package/public/fx/default-beep.mp3 +0 -0
- package/public/fx/echo-beep-idle.mp3 +0 -0
- package/public/fx/musical-beep-idle.mp3 +0 -0
- package/public/fx/small-bleep-idle.mp3 +0 -0
- package/public/fx/soft-beep.mp3 +0 -0
- package/public/fx/space-idle.mp3 +0 -0
- package/public/img/claude-code.png +0 -0
- package/public/img/clideck-logo-icon.png +0 -0
- package/public/img/clideck-logo-terminal-panel.png +0 -0
- package/public/img/codex.png +0 -0
- package/public/img/gemini.png +0 -0
- package/public/img/opencode.png +0 -0
- package/public/index.html +243 -0
- package/public/js/app.js +794 -0
- package/public/js/color-mode.js +51 -0
- package/public/js/confirm.js +27 -0
- package/public/js/creator.js +201 -0
- package/public/js/drag.js +134 -0
- package/public/js/folder-picker.js +81 -0
- package/public/js/hotkeys.js +90 -0
- package/public/js/nav.js +56 -0
- package/public/js/profiles.js +22 -0
- package/public/js/prompts.js +325 -0
- package/public/js/settings.js +489 -0
- package/public/js/state.js +15 -0
- package/public/js/terminals.js +905 -0
- package/public/js/toast.js +62 -0
- package/public/js/utils.js +27 -0
- package/public/tailwind.css +1 -0
- package/server.js +126 -0
- package/sessions.js +375 -0
- package/telemetry-receiver.js +129 -0
- package/themes.js +247 -0
- package/transcript.js +90 -0
- package/utils.js +66 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// OpenCode bridge — receives events from the CliDeck OpenCode plugin
|
|
2
|
+
// via HTTP POST to /opencode-events.
|
|
3
|
+
// Routes events to the correct CliDeck session by OpenCode session ID.
|
|
4
|
+
|
|
5
|
+
// sessionId → { opencodeSessionId, cwd }
|
|
6
|
+
const watchers = new Map();
|
|
7
|
+
|
|
8
|
+
let broadcastFn = null;
|
|
9
|
+
let sessionsFn = null;
|
|
10
|
+
|
|
11
|
+
function init(broadcast, getSessions) {
|
|
12
|
+
broadcastFn = broadcast;
|
|
13
|
+
sessionsFn = getSessions;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function watchSession(sessionId, cwd) {
|
|
17
|
+
if (watchers.has(sessionId)) return;
|
|
18
|
+
watchers.set(sessionId, { opencodeSessionId: null, cwd });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function findByOcId(ocSid) {
|
|
22
|
+
for (const [sessionId, w] of watchers) {
|
|
23
|
+
if (w.opencodeSessionId === ocSid) return sessionId;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function findUnclaimed(directory) {
|
|
29
|
+
let fallback = null;
|
|
30
|
+
for (const [sessionId, w] of watchers) {
|
|
31
|
+
if (w.opencodeSessionId) continue;
|
|
32
|
+
if (directory && w.cwd && directory.startsWith(w.cwd)) return sessionId;
|
|
33
|
+
if (!fallback) fallback = sessionId;
|
|
34
|
+
}
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Extract OpenCode session ID from any event shape
|
|
39
|
+
function extractOcSid(p) {
|
|
40
|
+
return p.sessionID
|
|
41
|
+
|| p.sessionId
|
|
42
|
+
|| p.info?.id
|
|
43
|
+
|| p.info?.sessionID
|
|
44
|
+
|| p.info?.sessionId
|
|
45
|
+
|| p.part?.sessionID
|
|
46
|
+
|| p.part?.sessionId
|
|
47
|
+
|| p.message?.sessionID
|
|
48
|
+
|| p.message?.sessionId
|
|
49
|
+
|| p.session?.id
|
|
50
|
+
|| null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function extractDirectory(p) {
|
|
54
|
+
return p.info?.directory || p.directory || p.info?.path?.cwd || p.path?.cwd || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function claim(sessionId, ocSid) {
|
|
58
|
+
const w = watchers.get(sessionId);
|
|
59
|
+
if (!w) return;
|
|
60
|
+
w.opencodeSessionId = ocSid;
|
|
61
|
+
const sess = sessionsFn?.()?.get(sessionId);
|
|
62
|
+
if (sess && !sess.sessionToken) sess.sessionToken = ocSid;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unclaimedIds() {
|
|
66
|
+
const ids = [];
|
|
67
|
+
for (const [sessionId, w] of watchers) {
|
|
68
|
+
if (!w.opencodeSessionId) ids.push(sessionId);
|
|
69
|
+
}
|
|
70
|
+
return ids;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function handleEvent(payload) {
|
|
74
|
+
if (!payload || !payload.event) return;
|
|
75
|
+
|
|
76
|
+
const ocSid = extractOcSid(payload);
|
|
77
|
+
let sessionId = ocSid ? findByOcId(ocSid) : null;
|
|
78
|
+
|
|
79
|
+
// Claim unclaimed watcher on session.created or session.updated
|
|
80
|
+
if (!sessionId && ocSid && (payload.event === 'session.created' || payload.event === 'session.updated')) {
|
|
81
|
+
sessionId = findUnclaimed(extractDirectory(payload));
|
|
82
|
+
if (sessionId) claim(sessionId, ocSid);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Fallback: if there's exactly one unclaimed OpenCode watcher, attach first seen session ID.
|
|
86
|
+
// This recovers when session.created/session.updated isn't delivered in-order.
|
|
87
|
+
if (!sessionId && ocSid) {
|
|
88
|
+
const unclaimed = unclaimedIds();
|
|
89
|
+
if (unclaimed.length === 1) {
|
|
90
|
+
sessionId = unclaimed[0];
|
|
91
|
+
claim(sessionId, ocSid);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!sessionId) return;
|
|
96
|
+
|
|
97
|
+
// session.status → busy/idle
|
|
98
|
+
if (payload.event === 'session.status') {
|
|
99
|
+
const t = payload.status?.type;
|
|
100
|
+
if (t === 'busy') broadcastFn?.({ type: 'session.status', id: sessionId, working: true });
|
|
101
|
+
else if (t === 'idle') broadcastFn?.({ type: 'session.status', id: sessionId, working: false });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// session.idle
|
|
105
|
+
if (payload.event === 'session.idle') {
|
|
106
|
+
broadcastFn?.({ type: 'session.status', id: sessionId, working: false });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// message.part.updated with type=text → preview
|
|
110
|
+
if (payload.event === 'message.part.updated') {
|
|
111
|
+
const part = payload.part || {};
|
|
112
|
+
const text = typeof part.text === 'string'
|
|
113
|
+
? part.text
|
|
114
|
+
: (typeof payload.delta === 'string' ? payload.delta : '');
|
|
115
|
+
const isTextual = part.type === 'text' || part.type === 'reasoning' || !!text;
|
|
116
|
+
if (isTextual && text) {
|
|
117
|
+
broadcastFn?.({ type: 'session.preview', id: sessionId, text: text.slice(0, 200) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// message.updated fallback preview (for payloads that don't emit text part updates)
|
|
122
|
+
if (payload.event === 'message.updated') {
|
|
123
|
+
const parts = payload.info?.parts;
|
|
124
|
+
if (Array.isArray(parts)) {
|
|
125
|
+
const latest = [...parts].reverse().find(p =>
|
|
126
|
+
typeof p?.text === 'string' && (p.type === 'text' || p.type === 'reasoning')
|
|
127
|
+
);
|
|
128
|
+
if (latest?.text) {
|
|
129
|
+
broadcastFn?.({ type: 'session.preview', id: sessionId, text: latest.text.slice(0, 200) });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// session.updated → capture title, ensure token
|
|
135
|
+
if (payload.event === 'session.updated') {
|
|
136
|
+
const sess = sessionsFn?.()?.get(sessionId);
|
|
137
|
+
if (sess) {
|
|
138
|
+
if (!sess.sessionToken) sess.sessionToken = ocSid;
|
|
139
|
+
if (payload.info?.title) sess.title = payload.info.title;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function clear(sessionId) {
|
|
145
|
+
watchers.delete(sessionId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { init, watchSession, handleEvent, clear };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// CliDeck bridge plugin for OpenCode
|
|
2
|
+
// Forwards session events to CliDeck server via HTTP POST.
|
|
3
|
+
// Install: copy to ~/.config/opencode/plugins/clideck-bridge.js
|
|
4
|
+
|
|
5
|
+
const CLIDECK_URL = "http://localhost:4000/opencode-events";
|
|
6
|
+
|
|
7
|
+
function post(payload) {
|
|
8
|
+
fetch(CLIDECK_URL, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "Content-Type": "application/json" },
|
|
11
|
+
body: JSON.stringify(payload),
|
|
12
|
+
}).catch(() => {});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const CliDeckBridge = async () => {
|
|
16
|
+
return {
|
|
17
|
+
event: async ({ event }) => {
|
|
18
|
+
const t = event.type;
|
|
19
|
+
if (t.startsWith("session.") || t.startsWith("message.")) {
|
|
20
|
+
post({ event: t, ...event.properties });
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "clideck",
|
|
3
|
+
"version": "1.22.2",
|
|
4
|
+
"description": "One screen for all your AI coding agents — run, monitor, and manage multiple CLI agents from a single browser tab",
|
|
5
|
+
"main": "server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"clideck": "bin/clideck.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node server.js",
|
|
11
|
+
"build:css": "tailwindcss -i src/input.css -o public/tailwind.css --minify",
|
|
12
|
+
"prepublishOnly": "npm run build:css"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"terminal",
|
|
16
|
+
"cli",
|
|
17
|
+
"ai",
|
|
18
|
+
"agent",
|
|
19
|
+
"claude",
|
|
20
|
+
"codex",
|
|
21
|
+
"gemini",
|
|
22
|
+
"opencode",
|
|
23
|
+
"dashboard",
|
|
24
|
+
"multiplexer",
|
|
25
|
+
"xterm"
|
|
26
|
+
],
|
|
27
|
+
"author": "Or Kuntzman",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"type": "commonjs",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/rustykuntz/clideck.git"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://clideck.dev/",
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
37
|
+
"@xterm/xterm": "^6.0.0",
|
|
38
|
+
"node-pty": "^1.1.0",
|
|
39
|
+
"ws": "^8.19.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"tailwindcss": "^3.4.19"
|
|
43
|
+
},
|
|
44
|
+
"engines": {
|
|
45
|
+
"node": ">=18"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/paths.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const { join } = require('path');
|
|
2
|
+
const { mkdirSync, existsSync, copyFileSync, cpSync, readdirSync } = require('fs');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const DATA_DIR = join(os.homedir(), '.clideck');
|
|
6
|
+
const LEGACY_DIR = __dirname;
|
|
7
|
+
const OLD_DATA_DIR = join(os.homedir(), '.termix');
|
|
8
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
9
|
+
|
|
10
|
+
// Migrate from ~/.termix/ to ~/.clideck/ (one-time rename migration)
|
|
11
|
+
if (existsSync(OLD_DATA_DIR)) {
|
|
12
|
+
for (const file of readdirSync(OLD_DATA_DIR, { withFileTypes: true })) {
|
|
13
|
+
const src = join(OLD_DATA_DIR, file.name);
|
|
14
|
+
const dest = join(DATA_DIR, file.name);
|
|
15
|
+
if (existsSync(dest)) continue;
|
|
16
|
+
try { cpSync(src, dest, { recursive: true }); } catch {}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Migrate legacy files from project root to ~/.clideck/ (one-time on upgrade)
|
|
21
|
+
const MIGRATE_FILES = ['config.json', 'sessions.json', 'custom-themes.json'];
|
|
22
|
+
for (const file of MIGRATE_FILES) {
|
|
23
|
+
const src = join(LEGACY_DIR, file);
|
|
24
|
+
const dest = join(DATA_DIR, file);
|
|
25
|
+
if (existsSync(src) && !existsSync(dest)) {
|
|
26
|
+
try { copyFileSync(src, dest); } catch {}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Migrate transcript JSONL files
|
|
30
|
+
const legacyTranscripts = join(LEGACY_DIR, 'data', 'transcripts');
|
|
31
|
+
const newTranscripts = join(DATA_DIR, 'transcripts');
|
|
32
|
+
if (existsSync(legacyTranscripts) && !existsSync(newTranscripts)) {
|
|
33
|
+
mkdirSync(newTranscripts, { recursive: true });
|
|
34
|
+
try {
|
|
35
|
+
for (const f of readdirSync(legacyTranscripts)) {
|
|
36
|
+
copyFileSync(join(legacyTranscripts, f), join(newTranscripts, f));
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { DATA_DIR };
|
package/plugin-loader.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
const { readdirSync, readFileSync, existsSync, mkdirSync, cpSync } = require('fs');
|
|
2
|
+
const { join, sep } = require('path');
|
|
3
|
+
const { DATA_DIR } = require('./paths');
|
|
4
|
+
|
|
5
|
+
const PLUGINS_DIR = join(DATA_DIR, 'plugins');
|
|
6
|
+
mkdirSync(PLUGINS_DIR, { recursive: true });
|
|
7
|
+
|
|
8
|
+
// Seed bundled plugins — copy if missing, update if bundled version is newer
|
|
9
|
+
const BUNDLED_DIR = join(__dirname, 'plugins');
|
|
10
|
+
if (existsSync(BUNDLED_DIR)) {
|
|
11
|
+
for (const entry of readdirSync(BUNDLED_DIR, { withFileTypes: true })) {
|
|
12
|
+
if (!entry.isDirectory()) continue;
|
|
13
|
+
const target = join(PLUGINS_DIR, entry.name);
|
|
14
|
+
if (!existsSync(target)) {
|
|
15
|
+
cpSync(join(BUNDLED_DIR, entry.name), target, { recursive: true });
|
|
16
|
+
console.log(`[plugin] seeded ${entry.name}`);
|
|
17
|
+
} else {
|
|
18
|
+
try {
|
|
19
|
+
const bundledManifest = JSON.parse(readFileSync(join(BUNDLED_DIR, entry.name, 'clideck-plugin.json'), 'utf8'));
|
|
20
|
+
const installedManifestFile = existsSync(join(target, 'clideck-plugin.json')) ? join(target, 'clideck-plugin.json') : join(target, 'termix-plugin.json');
|
|
21
|
+
const installedManifest = JSON.parse(readFileSync(installedManifestFile, 'utf8'));
|
|
22
|
+
if (bundledManifest.version !== installedManifest.version) {
|
|
23
|
+
cpSync(join(BUNDLED_DIR, entry.name), target, { recursive: true });
|
|
24
|
+
console.log(`[plugin] updated ${entry.name} ${installedManifest.version} → ${bundledManifest.version}`);
|
|
25
|
+
}
|
|
26
|
+
} catch {}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const plugins = new Map();
|
|
32
|
+
const inputHooks = [];
|
|
33
|
+
const outputHooks = [];
|
|
34
|
+
const statusHooks = [];
|
|
35
|
+
const sessionStatus = new Map(); // sessionId → boolean (dedup multi-client reports)
|
|
36
|
+
const frontendHandlers = new Map();
|
|
37
|
+
let broadcastFn = null;
|
|
38
|
+
let sessionsFn = null;
|
|
39
|
+
let getConfigFn = null;
|
|
40
|
+
let saveConfigFn = null;
|
|
41
|
+
const settingsChangeHandlers = new Map(); // pluginId → [fn]
|
|
42
|
+
|
|
43
|
+
function removeHooks(pluginId) {
|
|
44
|
+
for (const arr of [inputHooks, outputHooks, statusHooks]) {
|
|
45
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
46
|
+
if (arr[i].pluginId === pluginId) arr.splice(i, 1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const key of frontendHandlers.keys()) {
|
|
50
|
+
if (key.startsWith(`plugin.${pluginId}.`)) frontendHandlers.delete(key);
|
|
51
|
+
}
|
|
52
|
+
settingsChangeHandlers.delete(pluginId);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function init(broadcast, getSessions, getConfig, saveConfig) {
|
|
56
|
+
broadcastFn = broadcast;
|
|
57
|
+
sessionsFn = getSessions;
|
|
58
|
+
getConfigFn = getConfig;
|
|
59
|
+
saveConfigFn = saveConfig;
|
|
60
|
+
|
|
61
|
+
for (const entry of readdirSync(PLUGINS_DIR, { withFileTypes: true })) {
|
|
62
|
+
if (!entry.isDirectory()) continue;
|
|
63
|
+
const dir = join(PLUGINS_DIR, entry.name);
|
|
64
|
+
const entryFile = join(dir, 'index.js');
|
|
65
|
+
if (!existsSync(entryFile)) continue;
|
|
66
|
+
|
|
67
|
+
let manifest = { id: entry.name, name: entry.name, version: '0.0.0' };
|
|
68
|
+
const manifestFile = existsSync(join(dir, 'clideck-plugin.json')) ? join(dir, 'clideck-plugin.json') : join(dir, 'termix-plugin.json');
|
|
69
|
+
if (existsSync(manifestFile)) {
|
|
70
|
+
try { manifest = { ...manifest, ...JSON.parse(readFileSync(manifestFile, 'utf8')) }; }
|
|
71
|
+
catch (e) { console.error(`[plugin:${entry.name}] bad manifest: ${e.message}`); continue; }
|
|
72
|
+
}
|
|
73
|
+
// Validate settings shape
|
|
74
|
+
if (manifest.settings != null) {
|
|
75
|
+
if (!Array.isArray(manifest.settings)) {
|
|
76
|
+
console.error(`[plugin:${entry.name}] manifest.settings must be an array, ignoring`);
|
|
77
|
+
manifest.settings = [];
|
|
78
|
+
} else {
|
|
79
|
+
manifest.settings = manifest.settings.filter(s =>
|
|
80
|
+
s && typeof s === 'object' && typeof s.key === 'string' && s.key
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (plugins.has(manifest.id)) {
|
|
86
|
+
console.error(`[plugin:${manifest.id}] duplicate ID, skipping ${dir}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const state = { manifest, dir, shutdownFns: [], actions: [] };
|
|
91
|
+
plugins.set(manifest.id, state);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const mod = require(entryFile);
|
|
95
|
+
if (typeof mod.init === 'function') mod.init(buildApi(manifest.id, dir, state));
|
|
96
|
+
console.log(`[plugin] ${manifest.name} v${manifest.version}`);
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error(`[plugin:${manifest.id}] init failed: ${e.message}`);
|
|
99
|
+
removeHooks(manifest.id);
|
|
100
|
+
plugins.delete(manifest.id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function buildApi(pluginId, pluginDir, state) {
|
|
106
|
+
return {
|
|
107
|
+
version: 1,
|
|
108
|
+
pluginId,
|
|
109
|
+
pluginDir,
|
|
110
|
+
|
|
111
|
+
onSessionInput(fn) { inputHooks.push({ pluginId, fn }); },
|
|
112
|
+
onSessionOutput(fn) { outputHooks.push({ pluginId, fn }); },
|
|
113
|
+
onStatusChange(fn) { statusHooks.push({ pluginId, fn }); },
|
|
114
|
+
|
|
115
|
+
sendToFrontend(event, data = {}) {
|
|
116
|
+
broadcastFn?.({ ...data, type: `plugin.${pluginId}.${event}` });
|
|
117
|
+
},
|
|
118
|
+
onFrontendMessage(event, fn) {
|
|
119
|
+
frontendHandlers.set(`plugin.${pluginId}.${event}`, fn);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
getSession(id) {
|
|
123
|
+
const s = sessionsFn?.()?.get(id);
|
|
124
|
+
if (!s) return null;
|
|
125
|
+
return { id, name: s.name, cwd: s.cwd, commandId: s.commandId, themeId: s.themeId, projectId: s.projectId };
|
|
126
|
+
},
|
|
127
|
+
getSessions() {
|
|
128
|
+
const sessions = sessionsFn?.();
|
|
129
|
+
if (!sessions) return [];
|
|
130
|
+
return [...sessions].map(([id, s]) => ({
|
|
131
|
+
id, name: s.name, cwd: s.cwd, commandId: s.commandId, themeId: s.themeId, projectId: s.projectId,
|
|
132
|
+
}));
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
addToolbarAction(opts) { state.actions.push({ ...opts, pluginId, slot: 'toolbar' }); },
|
|
136
|
+
|
|
137
|
+
getSetting(key) {
|
|
138
|
+
const cfg = getConfigFn?.();
|
|
139
|
+
const defaults = {};
|
|
140
|
+
for (const s of state.manifest.settings || []) defaults[s.key] = s.default;
|
|
141
|
+
return cfg?.pluginSettings?.[pluginId]?.[key] ?? defaults[key];
|
|
142
|
+
},
|
|
143
|
+
getSettings() {
|
|
144
|
+
const cfg = getConfigFn?.();
|
|
145
|
+
const result = {};
|
|
146
|
+
for (const s of state.manifest.settings || []) result[s.key] = s.default;
|
|
147
|
+
return { ...result, ...cfg?.pluginSettings?.[pluginId] };
|
|
148
|
+
},
|
|
149
|
+
onSettingsChange(fn) {
|
|
150
|
+
if (!settingsChangeHandlers.has(pluginId)) settingsChangeHandlers.set(pluginId, []);
|
|
151
|
+
settingsChangeHandlers.get(pluginId).push(fn);
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
onShutdown(fn) { state.shutdownFns.push(fn); },
|
|
155
|
+
log(msg) { console.log(`[plugin:${pluginId}] ${msg}`); },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function transformInput(id, data) {
|
|
160
|
+
if (!inputHooks.length) return data;
|
|
161
|
+
let result = data;
|
|
162
|
+
for (const h of inputHooks) {
|
|
163
|
+
try {
|
|
164
|
+
const out = h.fn(id, result);
|
|
165
|
+
if (typeof out === 'string') result = out;
|
|
166
|
+
} catch (e) { console.error(`[plugin:${h.pluginId}] input error: ${e.message}`); }
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function notifyOutput(id, data) {
|
|
172
|
+
for (const h of outputHooks) {
|
|
173
|
+
try { h.fn(id, data); }
|
|
174
|
+
catch (e) { console.error(`[plugin:${h.pluginId}] output error: ${e.message}`); }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function notifyStatus(id, working) {
|
|
179
|
+
if (sessionStatus.get(id) === working) return;
|
|
180
|
+
sessionStatus.set(id, working);
|
|
181
|
+
for (const h of statusHooks) {
|
|
182
|
+
try { h.fn(id, working); }
|
|
183
|
+
catch (e) { console.error(`[plugin:${h.pluginId}] status error: ${e.message}`); }
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function updateSetting(pluginId, key, value) {
|
|
188
|
+
// Validate plugin exists (also prevents __proto__ pollution — Map lookup returns undefined)
|
|
189
|
+
const plugin = plugins.get(pluginId);
|
|
190
|
+
if (!plugin) return;
|
|
191
|
+
// Validate key is declared in manifest
|
|
192
|
+
const settingDef = (plugin.manifest.settings || []).find(s => s.key === key);
|
|
193
|
+
if (!settingDef) return;
|
|
194
|
+
// Type-coerce/validate value against manifest type
|
|
195
|
+
const coerced = coerceSetting(settingDef, value);
|
|
196
|
+
if (coerced === undefined) return;
|
|
197
|
+
|
|
198
|
+
const cfg = getConfigFn?.();
|
|
199
|
+
if (!cfg) return;
|
|
200
|
+
if (!cfg.pluginSettings) cfg.pluginSettings = Object.create(null);
|
|
201
|
+
if (!cfg.pluginSettings[pluginId]) cfg.pluginSettings[pluginId] = Object.create(null);
|
|
202
|
+
cfg.pluginSettings[pluginId][key] = coerced;
|
|
203
|
+
saveConfigFn?.(cfg);
|
|
204
|
+
const fns = settingsChangeHandlers.get(pluginId) || [];
|
|
205
|
+
for (const fn of fns) {
|
|
206
|
+
try { fn(key, coerced); }
|
|
207
|
+
catch (e) { console.error(`[plugin:${pluginId}] settings handler error: ${e.message}`); }
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function coerceSetting(def, value) {
|
|
212
|
+
switch (def.type) {
|
|
213
|
+
case 'toggle': return !!value;
|
|
214
|
+
case 'number': {
|
|
215
|
+
const n = Number(value);
|
|
216
|
+
if (!Number.isFinite(n)) return undefined;
|
|
217
|
+
if (def.min != null && n < def.min) return undefined;
|
|
218
|
+
if (def.max != null && n > def.max) return undefined;
|
|
219
|
+
return n;
|
|
220
|
+
}
|
|
221
|
+
case 'select': {
|
|
222
|
+
const opts = (def.options || []).map(o => String(typeof o === 'object' ? o.value : o));
|
|
223
|
+
const s = String(value);
|
|
224
|
+
return opts.includes(s) ? s : undefined;
|
|
225
|
+
}
|
|
226
|
+
default: return String(value);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function handleMessage(msg) {
|
|
231
|
+
const fn = frontendHandlers.get(msg.type);
|
|
232
|
+
if (!fn) return false;
|
|
233
|
+
try { fn(msg); }
|
|
234
|
+
catch (e) { console.error(`[plugin] handler error for ${msg.type}: ${e.message}`); }
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function getInfo() {
|
|
239
|
+
const cfg = getConfigFn?.();
|
|
240
|
+
return [...plugins.values()].map(p => ({
|
|
241
|
+
id: p.manifest.id,
|
|
242
|
+
name: p.manifest.name,
|
|
243
|
+
version: p.manifest.version,
|
|
244
|
+
settings: p.manifest.settings || [],
|
|
245
|
+
settingValues: cfg?.pluginSettings?.[p.manifest.id] || {},
|
|
246
|
+
actions: p.actions,
|
|
247
|
+
hasClient: existsSync(join(p.dir, 'client.js')),
|
|
248
|
+
}));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function resolveFile(urlPath) {
|
|
252
|
+
const m = urlPath.match(/^\/plugins\/([^/]+)\/(.+)$/);
|
|
253
|
+
if (!m) return null;
|
|
254
|
+
const [, id, rest] = m;
|
|
255
|
+
const plugin = plugins.get(id);
|
|
256
|
+
if (!plugin) return null;
|
|
257
|
+
let file, allowed;
|
|
258
|
+
if (rest === 'client.js') {
|
|
259
|
+
file = join(plugin.dir, 'client.js');
|
|
260
|
+
allowed = plugin.dir;
|
|
261
|
+
} else {
|
|
262
|
+
allowed = join(plugin.dir, 'public');
|
|
263
|
+
file = join(allowed, rest);
|
|
264
|
+
}
|
|
265
|
+
if (!file.startsWith(allowed + sep)) return null;
|
|
266
|
+
if (!existsSync(file)) return null;
|
|
267
|
+
return file;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function shutdown() {
|
|
271
|
+
for (const [id, p] of plugins) {
|
|
272
|
+
for (const fn of p.shutdownFns) {
|
|
273
|
+
try { fn(); }
|
|
274
|
+
catch (e) { console.error(`[plugin:${id}] shutdown error: ${e.message}`); }
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function clearStatus(id) { sessionStatus.delete(id); }
|
|
280
|
+
|
|
281
|
+
module.exports = {
|
|
282
|
+
init, shutdown,
|
|
283
|
+
transformInput, notifyOutput, notifyStatus, clearStatus,
|
|
284
|
+
handleMessage, updateSetting, getInfo, resolveFile,
|
|
285
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
let enabled = true;
|
|
2
|
+
let btnEl = null;
|
|
3
|
+
|
|
4
|
+
export function init(api) {
|
|
5
|
+
api.onMessage('settings', (msg) => {
|
|
6
|
+
enabled = msg.enabled !== false;
|
|
7
|
+
if (btnEl) btnEl.style.display = enabled ? '' : 'none';
|
|
8
|
+
});
|
|
9
|
+
api.send('getSettings');
|
|
10
|
+
|
|
11
|
+
btnEl = api.addToolbarButton({
|
|
12
|
+
title: 'Trim & Copy',
|
|
13
|
+
icon: '<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M20 4 8.12 15.88M14.47 14.48 20 20M8.12 8.12 12 12"/></svg>',
|
|
14
|
+
async onClick() {
|
|
15
|
+
const text = api.getTerminalSelection();
|
|
16
|
+
if (!text || !text.trim()) { api.toast('Select text to copy & trim', { type: 'warn' }); return; }
|
|
17
|
+
const trimmed = text
|
|
18
|
+
.split('\n')
|
|
19
|
+
.map(l => l.trimEnd())
|
|
20
|
+
.join('\n')
|
|
21
|
+
.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
22
|
+
try {
|
|
23
|
+
await navigator.clipboard.writeText(trimmed);
|
|
24
|
+
const saved = text.length - trimmed.length;
|
|
25
|
+
api.toast(saved ? `Copied & trimmed ${saved} char${saved !== 1 ? 's' : ''}` : 'Copied', { type: 'success' });
|
|
26
|
+
} catch {
|
|
27
|
+
api.toast('Clipboard access denied — allow it in browser site settings', { type: 'error' });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "voice-input",
|
|
3
|
+
"name": "Voice Input",
|
|
4
|
+
"version": "1.1.0",
|
|
5
|
+
"settings": [
|
|
6
|
+
{
|
|
7
|
+
"key": "enabled",
|
|
8
|
+
"label": "Enabled",
|
|
9
|
+
"type": "toggle",
|
|
10
|
+
"default": false
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"key": "backend",
|
|
14
|
+
"label": "ASR Backend",
|
|
15
|
+
"type": "select",
|
|
16
|
+
"default": "openai",
|
|
17
|
+
"options": ["openai", "local"],
|
|
18
|
+
"description": "OpenAI Whisper (remote) or local MLX model (macOS)"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"key": "openaiApiKey",
|
|
22
|
+
"label": "OpenAI API Key",
|
|
23
|
+
"type": "text",
|
|
24
|
+
"default": "",
|
|
25
|
+
"description": "Required for OpenAI backend"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"key": "language",
|
|
29
|
+
"label": "Language",
|
|
30
|
+
"type": "select",
|
|
31
|
+
"default": "auto",
|
|
32
|
+
"options": ["auto", "en", "es", "fr", "de", "it", "pt", "nl", "ja", "ko", "zh", "ar", "hi", "ru", "pl", "tr", "sv", "da", "fi", "no", "he", "uk", "el", "cs", "ro", "hu", "th", "vi", "id"]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"key": "hotkey",
|
|
36
|
+
"label": "Record Key",
|
|
37
|
+
"type": "text",
|
|
38
|
+
"default": "F4",
|
|
39
|
+
"description": "Key to start/stop recording (e.g. F4, F8)"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"key": "replacementsFile",
|
|
43
|
+
"label": "Replacements File",
|
|
44
|
+
"type": "text",
|
|
45
|
+
"default": "",
|
|
46
|
+
"description": "Path to text replacements file (wrong => correct per line)"
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|