peermsg
Version:
Lightweight peer-to-peer LAN messenger CLI with UDP broadcast/multicast, AES encryption, and full-screen TUI mode.
201 lines (173 loc) • 6.19 kB
JavaScript
// src/tui.js
import readline from "node:readline";
// Tiny renderer with no deps. Left: messages. Right: peers. Bottom: input.
export function runTui({ room, name, session, onExit }) {
const state = {
msgs: [], // {from, text, ts}
peers: [], // [{name, ip}]
scroll: 0, // 0 = bottom
input: "",
};
// Wire session handlers
session.onChat = (m) => {
state.msgs.push(m);
if (state.scroll === 0) ensureMax(state.msgs, 300);
draw();
};
session.onPeers = (peersArr) => {
state.peers = peersArr;
draw();
};
// Terminal setup
const tty = process.stdin.isTTY && process.stdout.isTTY;
if (!tty) {
console.error(
"TUI requires a TTY (interactive terminal). Try without --tui.",
);
onExit?.();
return;
}
readline.emitKeypressEvents(process.stdin);
process.stdin.setRawMode(true);
const cleanup = () => {
// reset terminal
write("\x1b[?25h"); // show cursor
write("\x1b[0m"); // reset colors
write("\x1b[2J\x1b[H"); // clear
process.stdin.setRawMode(false);
};
const exitAll = () => {
cleanup();
onExit?.();
};
process.on("SIGINT", () => exitAll());
process.on("SIGTERM", () => exitAll());
process.stdin.on("keypress", (_str, key) => {
if (!key) return;
if (key.ctrl && key.name === "c") return exitAll();
if (key.name === "escape") return exitAll();
switch (key.name) {
case "return":
case "enter": {
const text = state.input.trim();
if (text) {
session.send(text);
state.input = "";
}
break;
}
case "backspace":
case "delete":
state.input = state.input.slice(0, -1);
break;
case "up":
state.scroll = Math.min(
state.scroll + 1,
Math.max(0, state.msgs.length - 1),
);
break;
case "down":
state.scroll = Math.max(0, state.scroll - 1);
break;
case "pageup":
state.scroll = Math.min(
state.scroll + 10,
Math.max(0, state.msgs.length - 1),
);
break;
case "pagedown":
state.scroll = Math.max(0, state.scroll - 10);
break;
case "home":
state.scroll = Math.max(0, state.msgs.length - 1);
break;
case "end":
state.scroll = 0;
break;
default:
if (key.sequence && !key.ctrl && !key.meta) {
state.input += key.sequence;
}
}
draw();
});
function write(s) {
process.stdout.write(s);
}
function ensureMax(arr, max) {
if (arr.length > max) arr.splice(0, arr.length - max);
}
function pad(str, n) {
if (str.length >= n) return str.slice(0, n);
return str + " ".repeat(n - str.length);
}
function draw() {
const cols = process.stdout.columns || 100;
const rows = process.stdout.rows || 30;
const inputHeight = 3; // 1 line prompt + padding
const headerHeight = 2;
const footerHeight = 1;
const bodyRows = Math.max(
3,
rows - inputHeight - headerHeight - footerHeight,
);
const peersWidth = Math.max(18, Math.floor(cols * 0.24));
const msgsWidth = Math.max(20, cols - peersWidth - 3);
// compute visible slice from bottom with scroll offset
const visibleCount = bodyRows - 2; // minus inner paddings
const end = state.msgs.length - state.scroll;
const start = Math.max(0, end - visibleCount);
const visible = state.msgs.slice(start, end);
// clear & home
write("\x1b[?25l"); // hide cursor
write("\x1b[2J\x1b[H"); // clear screen
// Header
write(color(36, ` peermsg — room: ${room} as: ${name} `) + "\n");
write(
dim(" ↑/↓ scroll PgUp/PgDn faster Enter send Esc/Ctrl+C quit ") + "\n",
);
// Body frame
// Left box: messages
write("┌" + "─".repeat(msgsWidth) + "┬" + "─".repeat(peersWidth) + "┐\n");
// message lines
for (let i = 0; i < visibleCount; i++) {
const m = visible[i];
const line = m ? formatMsg(m, msgsWidth) : " ".repeat(msgsWidth);
const peerLine = state.peers[i]
? pad(state.peers[i].name, peersWidth)
: " ".repeat(peersWidth);
write("│" + line + "│" + dim(peerLine) + "│\n");
}
write("└" + "─".repeat(msgsWidth) + "┴" + "─".repeat(peersWidth) + "┘\n");
// Input
const prompt = color(33, "> ") + state.input;
const trimmed = prompt.length > cols ? prompt.slice(-cols) : prompt;
write(trimmed + "\n");
// Footer
const info = dim(
` msgs:${state.msgs.length} peers:${state.peers.length} ` +
(state.scroll ? ` [scrolled ${state.scroll}] ` : ""),
);
write(info);
// position cursor at end of input
write("\x1b[" + (rows - footerHeight) + ";1H"); // go to input line start
write("\x1b[" + (trimmed.length + 1) + "G"); // move to after text
write("\x1b[?25h"); // show cursor
}
function formatMsg(m, width) {
const prefix = `[${m.from}] `;
const space = Math.max(0, width - prefix.length);
const text =
m.text.length > space ? m.text.slice(0, space - 1) + "…" : m.text;
return color(32, prefix) + text.padEnd(space, " ");
}
function color(code, s) {
return `\x1b[${code}m${s}\x1b[0m`;
}
function dim(s) {
return `\x1b[2m${s}\x1b[0m`;
}
// Initial paint
draw();
return { exit: exitAll };
}