Files
lab-console-tmux/scripts/lib/nvim_ui_bridge.mjs
T

448 lines
14 KiB
JavaScript

import net from 'node:net';
import path from 'node:path';
import { readFile, realpath } from 'node:fs/promises';
import { decodeMultiStream, encode } from '@msgpack/msgpack';
const OPEN = 1;
const DEFAULT_COLUMNS = 190;
const DEFAULT_ROWS = 50;
const RETRY_DELAY_MS = 1200;
const TARGET_POLL_MS = 750;
const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
function errorText(error) {
return error instanceof Error ? error.message : String(error);
}
function delay(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
async function selectedTarget(selectionRoot) {
const current = path.join(selectionRoot, 'current');
const socketLink = path.join(current, 'n.sock');
const [socketPath, metadataText] = await Promise.all([
realpath(socketLink),
readFile(path.join(current, 'selection.json'), 'utf8')
]);
const metadata = JSON.parse(metadataText);
return {
socketPath,
metadata,
epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}`
};
}
class NvimRpcClient {
constructor(socketPath, onNotification) {
this.socketPath = socketPath;
this.onNotification = onNotification;
this.socket = new net.Socket();
this.nextMessageId = 1;
this.pending = new Map();
this.closed = false;
this.readLoop = null;
}
async connect() {
await new Promise((resolve, reject) => {
const connected = () => {
this.socket.off('error', failed);
resolve();
};
const failed = error => {
this.socket.off('connect', connected);
reject(error);
};
this.socket.once('connect', connected);
this.socket.once('error', failed);
this.socket.connect(this.socketPath);
});
this.readLoop = this.consume().catch(error => {
this.rejectPending(error);
if (!this.closed) this.socket.destroy(error);
throw error;
});
}
async consume() {
for await (const message of decodeMultiStream(this.socket)) {
if (!Array.isArray(message)) continue;
if (message[0] === 1) {
const pending = this.pending.get(message[1]);
if (!pending) continue;
this.pending.delete(message[1]);
if (message[2]) pending.reject(new Error(JSON.stringify(message[2])));
else pending.resolve(message[3]);
} else if (message[0] === 2) {
this.onNotification(message[1], message[2]);
}
}
throw new Error('Neovim zamknął połączenie RPC.');
}
call(method, parameters = []) {
if (this.closed) return Promise.reject(new Error('Połączenie RPC jest zamknięte.'));
const id = this.nextMessageId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket.write(encode([0, id, method, parameters]), error => {
if (!error) return;
this.pending.delete(id);
reject(error);
});
});
}
rejectPending(error) {
for (const pending of this.pending.values()) pending.reject(error);
this.pending.clear();
}
close() {
if (this.closed) return;
this.closed = true;
this.rejectPending(new Error('Połączenie RPC zostało zamknięte.'));
this.socket.destroy();
}
}
function safeSend(websocket, message) {
if (websocket.readyState !== OPEN) return;
if (websocket.bufferedAmount > MAX_BUFFERED_BYTES) {
websocket.close(1013, 'Neovim UI client is too slow');
return;
}
websocket.send(JSON.stringify(message));
}
function existingUiSize(uis) {
const valid = Array.isArray(uis)
? uis.filter(item => Number.isInteger(item?.width) && Number.isInteger(item?.height))
: [];
const terminal = valid.find(item => item?.stdin_tty && item?.stdout_tty);
if (terminal) {
return { width: terminal.width, height: terminal.height };
}
return {
// Without a terminal UI there is nothing to mirror, so retain a useful
// external-UI fallback size until the selected terminal is attached again.
width: Math.max(DEFAULT_COLUMNS, ...valid.map(item => item.width)),
height: Math.max(DEFAULT_ROWS, ...valid.map(item => item.height))
};
}
class NvimUiHub {
constructor(selectionRoot, onEmpty) {
this.selectionRoot = selectionRoot;
this.onEmpty = onEmpty;
this.clients = new Set();
this.rpc = null;
this.stopped = false;
this.started = false;
this.status = { type: 'status', state: 'connecting' };
this.pendingResize = null;
this.resizeTimer = null;
this.appliedUiSize = '';
this.uiSize = { width: DEFAULT_COLUMNS, height: DEFAULT_ROWS };
this.uiAttached = false;
this.refreshingUi = null;
}
broadcast(message) {
this.status = message.type === 'status' ? message : this.status;
for (const client of this.clients) safeSend(client, message);
}
add(websocket) {
this.clients.add(websocket);
safeSend(websocket, this.status);
const remove = () => this.remove(websocket);
websocket.once('close', remove);
websocket.once('error', remove);
websocket.on('message', payload => this.handleMessage(payload));
if (!this.started) {
this.started = true;
void this.run().catch(error => {
this.broadcast({ type: 'status', state: 'offline', message: errorText(error) });
});
} else if (this.rpc && this.uiAttached) {
// A newly opened browser has no copy of redraw events emitted before it
// joined the shared hub. Reattaching only this external UI makes Neovim
// send one complete line-grid frame. The real TTY UI stays attached.
void this.refreshUi().catch(() => undefined);
}
}
refreshUi() {
if (this.refreshingUi) return this.refreshingUi;
const rpc = this.rpc;
const size = this.uiSize;
if (!rpc || !this.uiAttached) return Promise.resolve();
this.refreshingUi = (async () => {
await rpc.call('nvim_ui_detach');
if (rpc !== this.rpc || this.stopped) return;
this.uiAttached = false;
await rpc.call('nvim_ui_attach', [size.width, size.height, {
rgb: false,
ext_linegrid: true,
ext_multigrid: false,
override: false
}]);
if (rpc === this.rpc) this.uiAttached = true;
})().finally(() => {
this.refreshingUi = null;
});
return this.refreshingUi;
}
remove(websocket) {
this.clients.delete(websocket);
if (this.clients.size > 0) return;
this.stopped = true;
if (this.resizeTimer) clearTimeout(this.resizeTimer);
this.rpc?.close();
this.onEmpty();
}
scheduleResize(columns, rows) {
this.pendingResize = { columns, rows };
if (this.resizeTimer) clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
this.resizeTimer = null;
void this.applyResize().catch(() => undefined);
}, 90);
}
async resizeToTerminal() {
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
this.resizeTimer = null;
}
this.pendingResize = {
columns: this.uiSize.width,
rows: this.uiSize.height
};
await this.applyResize();
return this.uiSize;
}
async applyResize() {
const requested = this.pendingResize;
const rpc = this.rpc;
this.pendingResize = null;
if (!requested || !rpc || this.stopped) return;
const uis = await rpc.call('nvim_list_uis');
if (rpc !== this.rpc) return;
const terminalUi = Array.isArray(uis)
? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty)
: null;
const columns = Number.isInteger(terminalUi?.width)
? terminalUi.width
: requested.columns;
const rows = Number.isInteger(terminalUi?.height)
? terminalUi.height
: requested.rows;
const size = `${columns}x${rows}`;
if (size === this.appliedUiSize) return;
await rpc.call('nvim_ui_try_resize', [columns, rows]);
if (rpc !== this.rpc) return;
await rpc.call('nvim_command', ['silent! StudentLayoutFit']);
this.uiSize = { width: columns, height: rows };
this.appliedUiSize = size;
}
handleMessage(payload) {
if (!this.rpc || this.stopped) return;
try {
const message = JSON.parse(payload.toString('utf8'));
if (message?.type === 'refresh') {
void this.resizeToTerminal()
.then(() => this.refreshUi())
.catch(() => undefined);
return;
}
if (message?.type === 'input' && typeof message.keys === 'string' && message.keys.length <= 256) {
void this.rpc.call('nvim_input', [message.keys]).catch(() => undefined);
return;
}
if (message?.type !== 'mouse') return;
const button = ['left', 'middle', 'right', 'wheel'].includes(message.button)
? message.button
: null;
const action = ['press', 'drag', 'release', 'up', 'down'].includes(message.action)
? message.action
: null;
const row = Number(message.row);
const column = Number(message.column);
if (!button || !action || !Number.isInteger(row) || !Number.isInteger(column)) return;
void this.rpc.call('nvim_input_mouse', [
button,
action,
typeof message.modifier === 'string' ? message.modifier.slice(0, 16) : '',
0,
Math.max(0, row),
Math.max(0, column)
]).catch(() => undefined);
} catch {
// Only the typed input/mouse/refresh capabilities above are exposed.
}
}
async run() {
while (!this.stopped && this.clients.size > 0) {
let selected;
try {
selected = await selectedTarget(this.selectionRoot);
} catch (error) {
this.broadcast({
type: 'status',
state: 'unselected',
message: `Brak aktywnego celu MCP: ${errorText(error)}`
});
await delay(RETRY_DELAY_MS);
continue;
}
this.broadcast({
type: 'status',
state: 'connecting',
epoch: selected.epoch,
target: selected.metadata
});
let targetTimer = null;
try {
this.rpc = new NvimRpcClient(selected.socketPath, (method, parameters) => {
if (method !== 'redraw') return;
this.broadcast({
type: 'redraw',
epoch: selected.epoch,
events: parameters
});
});
await this.rpc.connect();
this.appliedUiSize = '';
this.uiAttached = false;
void this.rpc.readLoop.catch(() => undefined);
const uis = await this.rpc.call('nvim_list_uis');
const size = existingUiSize(uis);
this.uiSize = size;
await this.rpc.call('nvim_set_client_info', [
'stem-card-browser',
{ major: 0, minor: 1, patch: 0 },
'ui',
{},
{ website: 'local://stem-card', license: 'private' }
]);
await this.rpc.call('nvim_ui_attach', [size.width, size.height, {
rgb: false,
ext_linegrid: true,
ext_multigrid: false,
override: false
}]);
this.uiAttached = true;
this.broadcast({
type: 'status',
state: 'connected',
epoch: selected.epoch,
target: selected.metadata,
grid: size,
control: 'typed-input'
});
const targetChanged = new Promise((_, reject) => {
targetTimer = setInterval(async () => {
try {
const current = await selectedTarget(this.selectionRoot);
if (current.epoch !== selected.epoch) {
reject(new Error('Zmieniono cel MCP.'));
}
} catch (error) {
reject(error);
}
}, TARGET_POLL_MS);
});
await Promise.race([this.rpc.readLoop, targetChanged]);
} catch (error) {
if (!this.stopped) {
this.broadcast({
type: 'status',
state: 'offline',
epoch: selected.epoch,
target: selected.metadata,
message: errorText(error)
});
}
} finally {
if (targetTimer) clearInterval(targetTimer);
this.uiAttached = false;
this.rpc?.close();
this.rpc = null;
}
if (!this.stopped) await delay(RETRY_DELAY_MS);
}
}
}
const hubs = new Map();
function selectionRootFor(options = {}) {
return options.selectionRoot
?? process.env.MCP_CONTAINER_SELECTION_ROOT
?? path.join(process.env.XDG_RUNTIME_DIR ?? '/run/user/1000', 'stem/mcp-selected');
}
export async function callSelectedNvim(method, parameters = [], options = {}) {
const selectionRoot = selectionRootFor(options);
const hub = hubs.get(selectionRoot);
const pinnedSocketPath = options.socketPath
? await realpath(options.socketPath)
: null;
if (
hub?.rpc
&& !hub.stopped
&& (!pinnedSocketPath || hub.rpc.socketPath === pinnedSocketPath)
) {
return hub.rpc.call(method, parameters);
}
const socketPath = pinnedSocketPath ?? (await selectedTarget(selectionRoot)).socketPath;
const rpc = new NvimRpcClient(socketPath, () => undefined);
await rpc.connect();
void rpc.readLoop.catch(() => undefined);
try {
return await rpc.call(method, parameters);
} finally {
rpc.close();
}
}
export async function resizeSelectedNvimUi(options = {}) {
const selectionRoot = selectionRootFor(options);
const hub = hubs.get(selectionRoot);
if (!hub?.rpc || hub.stopped || !hub.uiAttached) {
return { attached: false, grid: null };
}
const grid = await hub.resizeToTerminal();
await hub.refreshUi();
return { attached: true, grid };
}
export async function selectedNvimTarget(options = {}) {
return selectedTarget(selectionRootFor(options));
}
/**
* Attach a browser to the one shared external UI for the selected container.
* The browser never receives the Unix socket and cannot issue arbitrary RPC.
*/
export function attachNvimUi(websocket, options = {}) {
const selectionRoot = selectionRootFor(options);
let hub = hubs.get(selectionRoot);
if (!hub || hub.stopped) {
hub = new NvimUiHub(selectionRoot, () => hubs.delete(selectionRoot));
hubs.set(selectionRoot, hub);
}
hub.add(websocket);
}