mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-11 10:59:06 +08:00
[feat] add MCP terminal views and agent cursor overlays
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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(() => {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user