mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-05 21:52:46 +08:00
126 lines
6.6 KiB
TypeScript
126 lines
6.6 KiB
TypeScript
import { createInterface } from "node:readline/promises";
|
||
import { emitKeypressEvents } from "node:readline";
|
||
import { Writable } from "node:stream";
|
||
import { setTimeout as delay } from "node:timers/promises";
|
||
import { Failure, failure, type Data } from "./common.js";
|
||
import { TerminalClient } from "./terminal.js";
|
||
|
||
async function connect(client: TerminalClient) {
|
||
let hidden = false;
|
||
const output = new Writable({ write(chunk, _encoding, done) {
|
||
if (!hidden) process.stdout.write(chunk);
|
||
done();
|
||
} });
|
||
const rl = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
|
||
const ask = async (label: string) => (await rl.question(label)).trim();
|
||
try {
|
||
const { devices, defaults } = await client.call("devices");
|
||
const status = await client.call("status");
|
||
process.stdout.write("CrossDesk MCP · 终端画面\n");
|
||
for (const d of devices)
|
||
process.stdout.write(`${d.device_alias} (${d.device_id})\n`);
|
||
const selected = (await ask(`设备别名或 9 位 ID${status.device_alias ? ` [${status.device_alias}]` : ""}:`)) || status.device_alias;
|
||
const device = devices.find((d: Data) => d.device_alias === selected);
|
||
const args: Data = device ? { device_alias: selected } : {
|
||
device_id: selected,
|
||
server: (await ask(`服务器 [${defaults.server}]:`)) || defaults.server,
|
||
port: Number((await ask(`端口 [${defaults.port}]:`)) || defaults.port),
|
||
};
|
||
args.mode = (await ask("允许 Agent 操作?[y/N]:")).toLowerCase() === "y" ? "control" : "observe";
|
||
args.display_index = Number((await ask("显示器索引 [0]:")) || "0");
|
||
const saved = device?.credential_provider !== "none" && !!device;
|
||
if (!saved || (await ask("使用已保存凭据?[Y/n]:")).toLowerCase() === "n") {
|
||
process.stdout.write("访问密码(隐藏输入,可为空):");
|
||
hidden = true;
|
||
try { args.password = await rl.question(""); }
|
||
finally { hidden = false; process.stdout.write("\n"); }
|
||
}
|
||
process.stdout.write("正在连接…\n");
|
||
try { await client.call("connect", args); }
|
||
finally { args.password = undefined; }
|
||
} finally { rl.close(); }
|
||
}
|
||
|
||
export async function terminalView(directory: string, view: string, cellAspect = 0.5) {
|
||
if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.TERM === "dumb")
|
||
throw new Failure("VIEW_REQUIRES_TERMINAL");
|
||
if (!Number.isFinite(cellAspect) || cellAspect < 0.2 || cellAspect > 2)
|
||
throw new Failure("INVALID_ARGUMENT");
|
||
const client = await TerminalClient.attach(directory, view);
|
||
let raw = false, stopped = false, controls = false, message = "";
|
||
const previousRaw = process.stdin.isRaw;
|
||
const stop = () => { stopped = true; };
|
||
const keypress = (_text: string, key: { name?: string; ctrl?: boolean; meta?: boolean; sequence?: string }) => {
|
||
if (key.name === "q" || (key.ctrl && key.name === "c")) { stop(); return; }
|
||
if (controls || key.ctrl || key.meta || !["p", "r", "d"].includes(key.sequence ?? "")) return;
|
||
controls = true;
|
||
void client.call("control", { operation: key.sequence === "p" ? "pause" : key.sequence === "r" ? "resume" : "disconnect" })
|
||
.then(() => { message = ""; if (key.sequence === "d") stopped = true; })
|
||
.catch((error) => { message = failure(error).code; })
|
||
.finally(() => { controls = false; });
|
||
};
|
||
const write = (value: string) => new Promise<void>((resolve, reject) => {
|
||
process.stdout.write(value, (error) => error ? reject(error) : resolve());
|
||
});
|
||
try {
|
||
let status = await client.call("status");
|
||
if (!status.session_id) await connect(client);
|
||
process.stdin.setRawMode(true);
|
||
raw = true;
|
||
emitKeypressEvents(process.stdin);
|
||
process.stdin.on("keypress", keypress);
|
||
process.stdin.resume();
|
||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(signal, stop);
|
||
await write("\x1b[?1049h\x1b[?25l\x1b[?7l\x1b[2J");
|
||
let statusAt = 0, size = "";
|
||
const colors = /truecolor|24bit/i.test(process.env.COLORTERM ?? "") ? 24
|
||
: /256color/i.test(process.env.TERM ?? "") ? 256 : 8;
|
||
while (!stopped) {
|
||
const started = Date.now();
|
||
if (started - statusAt >= 500) {
|
||
status = await client.call("status", {}, 7000);
|
||
statusAt = Date.now();
|
||
if (["closed", "failed", "disconnected"].includes(status.state)) break;
|
||
}
|
||
// Leave a spare column so terminals with unusual wrap behavior stay safe.
|
||
const columns = Math.min(160, Math.max(1, (process.stdout.columns || 80) - 1));
|
||
const rows = Math.min(60, Math.max(1, (process.stdout.rows || 24) - 2));
|
||
const nextSize = `${columns}:${rows}`;
|
||
let screen = nextSize !== size ? "\x1b[2J" : "";
|
||
size = nextSize;
|
||
try {
|
||
const frame = await client.call("frame", { columns, rows, colors, cell_aspect: cellAspect }, 7000);
|
||
if (frame.columns !== columns || frame.rows !== rows || !Array.isArray(frame.lines) || frame.lines.length !== rows)
|
||
throw new Failure("INVALID_FRAME");
|
||
// Only renderer-generated SGR sequences and single-width block glyphs.
|
||
for (const [index, line] of frame.lines.entries()) {
|
||
if (typeof line !== "string" || !/^(?:\x1b\[[0-9;]*m|[ \u2580-\u259f])*$/u.test(line))
|
||
throw new Failure("INVALID_FRAME");
|
||
screen += `\x1b[${index + 1};1H${line}\x1b[0m\x1b[K`;
|
||
}
|
||
const cursor = frame.cursor;
|
||
if (cursor && Number.isInteger(cursor.column) && Number.isInteger(cursor.row) &&
|
||
cursor.column >= 0 && cursor.column < columns && cursor.row >= 0 && cursor.row < rows)
|
||
screen += `\x1b[${cursor.row + 1};${cursor.column + 1}H\x1b[97;44m↖\x1b[0m`;
|
||
} catch (error) {
|
||
if (!["NOT_READY", "BUSY", "FRAME_TIMEOUT", "STALE_OBSERVATION"].includes(failure(error).code)) throw error;
|
||
}
|
||
const label = status.human_paused ? "PAUSED" : status.control ? "AGENT CONTROL" : "OBSERVE";
|
||
const bar = `${label} | p pause / r resume / d disconnect / q close view${message ? ` | ${message}` : ""}`;
|
||
// No remote strings or credentials are interpolated into terminal control output.
|
||
screen += `\x1b[${rows + 1};1H\x1b[0m${bar.replace(/[^\x20-\x7e]/g, "").slice(0, columns)}\x1b[K`;
|
||
await write(screen);
|
||
await delay(Math.max(0, 125 - (Date.now() - started)));
|
||
}
|
||
} finally {
|
||
process.stdin.off("keypress", keypress);
|
||
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.off(signal, stop);
|
||
if (raw) {
|
||
process.stdin.setRawMode(previousRaw);
|
||
process.stdin.pause();
|
||
process.stdout.write("\x1b[0m\x1b[?7h\x1b[?25h\x1b[?1049l");
|
||
}
|
||
await client.call("detach", {}, 1000).catch(() => {});
|
||
}
|
||
}
|