Connect the bottom prompt to the shared SSH server

The bottom prompt is now a real text input. Sending the first command
lazy-loads an xterm.js terminal that continues right below it and
connects to wss://web.nowhere.wirs.ing with a minimal in-browser SSH
client (curve25519, strict kex, pinned ed25519 host key,
chacha20-poly1305, re-keying) using public/root_key.

The page is the scrollback: lines scrolling off the window-sized
terminal screen become page text, the screen grows with the lines in
use, and the wheel scrolls the page except in full-screen apps.
Typing anywhere on the page goes to the prompt, later to the terminal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zebreus 2026-10-25 00:58:38 +02:00
commit 83e25907a8
7 changed files with 511 additions and 102 deletions

59
package-lock.json generated
View file

@ -6,6 +6,11 @@
"": {
"name": "kik-website",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"astro": "^7.4.2"
}
},
@ -1057,6 +1062,45 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
"node_modules/@noble/ciphers": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
"integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.4.0"
},
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@ -1482,6 +1526,21 @@
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
"license": "ISC"
},
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/am-i-vibing": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz",

View file

@ -8,6 +8,11 @@
"preview": "astro preview"
},
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"astro": "^7.4.2"
}
}

View file

@ -1 +1 @@
<p class="prompt"><b>root@khaos-im-kiez</b>:~# <slot /><span class="cursor"></span></p>
<p class="prompt"><b>root@khaos-im-kiez</b>:~# <slot /></p>

View file

@ -1,110 +1,36 @@
---
import Prompt from './Prompt.astro';
---
<Prompt><span id="term-input"></span></Prompt>
<Prompt><input id="term-input" aria-label="Terminal" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send"><span class="cursor"></span></Prompt>
<script>
const term = document.getElementById('term-input');
let buf = '';
let ws = null;
let connected = false;
let firstEnter = true;
// A simple text input for the first command. The real terminal is only loaded once that command is sent.
import type { Terminal } from '@xterm/xterm';
function appendOutput(text){
if(!text) return;
// append to term display (simple)
term.textContent += text;
}
const input = document.getElementById('term-input') as HTMLInputElement;
const prompt = input.parentElement!;
let terminal: Terminal | undefined;
document.addEventListener('keydown', function(e){
const t = e.target;
if(t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
if(e.ctrlKey || e.altKey || e.metaKey) return;
// Typing anywhere on the page goes to the prompt, later to the terminal. Shortcuts, Space (scrolling) and focused links are left alone.
addEventListener('keydown', (e) => {
if (e.target !== document.body || e.key.length !== 1 || e.key === ' ' || e.metaKey || e.ctrlKey !== e.altKey) return;
if (!terminal) return input.focus(); // the key itself then lands in the input
e.preventDefault();
terminal.focus();
terminal.input(e.key);
});
prompt.addEventListener('click', () => input.focus());
input.addEventListener('input', () => (input.style.width = `${input.value.length}ch`));
if(e.key === 'Backspace'){
e.preventDefault();
if(connected && ws && ws.readyState === WebSocket.OPEN){
ws.send('\b');
return;
}
buf = buf.slice(0, -1);
term.textContent = buf;
return;
}
if(e.key === 'Enter'){
e.preventDefault();
if(firstEnter && !connected){
// open ssh over websocket connection with username/password auth
// keep connection open; run command after connection
const line = buf;
buf = '';
firstEnter = false;
term.textContent = term.textContent + '\n';
// try to connect - endpoint can be configured
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ssh';
try {
ws = new WebSocket(wsUrl);
ws.onopen = function(){
connected = true;
// send auth info and/or initial command
try {
ws.send(JSON.stringify({type:'auth', line: line}));
} catch(err) {
ws.send(line + '\n');
}
ws.send('\n'); // run command/start shell
};
ws.onmessage = function(ev){
appendOutput(ev.data);
};
ws.onclose = function(){
connected = false;
};
ws.onerror = function(){
connected = false;
};
} catch(err) {
// fallback
}
return;
}
if(connected && ws && ws.readyState === WebSocket.OPEN){
ws.send('\r');
term.textContent = term.textContent + '\n';
return;
}
buf += '\n';
term.textContent = buf;
return;
}
if(e.key === 'Escape'){
e.preventDefault();
if(connected && ws && ws.readyState === WebSocket.OPEN){
ws.send('\x1b');
return;
}
buf = '';
term.textContent = buf;
return;
}
if(e.key === 'Tab'){
e.preventDefault();
if(connected && ws && ws.readyState === WebSocket.OPEN){
ws.send('\t');
return;
}
return;
}
if(e.key.length === 1){
e.preventDefault();
if(connected && ws && ws.readyState === WebSocket.OPEN){
ws.send(e.key);
// don't echo locally if server echoes; but keep simple
return;
}
buf += e.key;
term.textContent = buf;
return;
}
input.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || e.isComposing) return;
e.preventDefault();
const command = input.value;
input.replaceWith(command);
prompt.querySelector('.cursor')!.remove(); // the terminal's cursor continues on the next line
import('../scripts/terminal')
.then(({ start }) => start(prompt, command))
.then((started) => (terminal = started))
.catch((error) => prompt.append(` [${error.message}]`));
});
</script>

276
src/scripts/ssh.ts Normal file
View file

@ -0,0 +1,276 @@
// Minimal SSH client for one interactive shell over a websockify bridge (raw SSH bytes in binary frames).
// Supports exactly: curve25519-sha256 with strict kex, ssh-ed25519 host and user keys,
// chacha20-poly1305@openssh.com, one session channel with a pty, and server-initiated re-keying.
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { chacha20orig } from '@noble/ciphers/chacha.js';
import { poly1305 } from '@noble/ciphers/_poly1305.js';
import { concatBytes as concat, equalBytes, randomBytes } from '@noble/ciphers/utils.js';
export interface Options {
socket: WebSocket;
user: string;
privateKey: string; // unencrypted ed25519 key in openssh-key-v1 format
hostKey: string; // expected host key fingerprint, "SHA256:..."
term: string;
cols: number;
rows: number;
onData: (data: Uint8Array) => void;
onClose: (error?: Error) => void;
}
export interface Session {
write(data: string | Uint8Array): void;
resize(cols: number, rows: number): void;
}
const CIPHER = 'chacha20-poly1305@openssh.com';
const WINDOW = 1 << 16;
const MAX_PACKET = 1 << 15;
const enc = new TextEncoder();
const dec = new TextDecoder();
const u32 = (n: number) => Uint8Array.of(n >>> 24, n >>> 16, n >>> 8, n);
/** SSH wire encoding: number → uint32, boolean → byte, string or bytes → length-prefixed string. */
function pack(...values: (number | boolean | string | Uint8Array)[]): Uint8Array {
return concat(...values.map((v) => {
if (typeof v === 'number') return u32(v);
if (typeof v === 'boolean') return Uint8Array.of(+v);
const bytes = typeof v === 'string' ? enc.encode(v) : v;
return concat(u32(bytes.length), bytes);
}));
}
const msg = (type: number, ...values: Parameters<typeof pack>) => concat(Uint8Array.of(type), pack(...values));
function reader(bytes: Uint8Array, offset = 0) {
const r = {
u8: () => bytes[offset++],
u32: () => new DataView(bytes.buffer, bytes.byteOffset).getUint32((offset += 4) - 4),
bytes: () => { const n = r.u32(); return bytes.subarray(offset, (offset += n)); },
str: () => dec.decode(r.bytes()),
};
return r;
}
/** Unsigned big-endian integer → SSH mpint. */
function mpint(bytes: Uint8Array) {
const n = bytes.subarray(bytes.findIndex((b) => b !== 0));
return pack(n[0] & 0x80 ? concat(Uint8Array.of(0), n) : n);
}
function parsePrivateKey(text: string) {
const r = reader(Uint8Array.from(atob(text.replace(/-----[^-]+-----|\s/g, '')), (c) => c.charCodeAt(0)), 15); // skip "openssh-key-v1\0"
if (r.str() !== 'none') throw new Error('encrypted private keys are not supported');
r.str(); r.bytes(); r.u32(); // kdf name, kdf options, number of keys
const publicKey = r.bytes();
const secret = reader(r.bytes(), 8); // skip check ints
secret.str(); secret.bytes(); // key type, public key
return { publicKey, seed: secret.bytes().subarray(0, 32) };
}
/** chacha20-poly1305@openssh.com. Sequence numbers restart at 0 after every NEWKEYS because of strict kex. */
function chachaPoly(key: Uint8Array) {
const main = key.subarray(0, 32);
const header = key.subarray(32);
let seq = 0;
const nonce = () => concat(u32(0), u32(seq));
const mac = (n: Uint8Array, data: Uint8Array) => poly1305(data, chacha20orig(main, n, new Uint8Array(32)));
return {
length: (head: Uint8Array) => reader(chacha20orig(header, nonce(), head)).u32(),
seal(packet: Uint8Array) {
const n = nonce();
seq++;
const sealed = concat(chacha20orig(header, n, packet.subarray(0, 4)), chacha20orig(main, n, packet.subarray(4), undefined, 1));
return concat(sealed, mac(n, sealed));
},
open(packet: Uint8Array) {
const n = nonce();
seq++;
const sealed = packet.subarray(0, -16);
if (!equalBytes(mac(n, sealed), packet.subarray(-16))) throw new Error('bad packet MAC');
return chacha20orig(main, n, sealed.subarray(4), undefined, 1);
},
};
}
export function connect(o: Options): Session {
const { socket } = o;
let buffer = new Uint8Array(0);
let wake = () => {};
let closed = false;
socket.binaryType = 'arraybuffer';
socket.onmessage = (e) => { buffer = concat(buffer, new Uint8Array(e.data)); wake(); };
socket.onclose = () => { closed = true; wake(); };
async function read(n: number) {
while (buffer.length < n) {
if (closed) throw new Error('connection closed');
await new Promise<void>((resolve) => (wake = resolve));
}
const bytes = buffer.subarray(0, n);
buffer = buffer.subarray(n);
return bytes;
}
let tx: ReturnType<typeof chachaPoly> | undefined;
let rx: ReturnType<typeof chachaPoly> | undefined;
let held: Uint8Array[] | undefined; // connection-layer messages held back during a key exchange
function send(payload: Uint8Array) {
if (socket.readyState !== WebSocket.OPEN) return; // the session has ended
if (held && payload[0] >= 50) return void held.push(payload);
let padding = 8 - ((payload.length + (tx ? 1 : 5)) % 8); // chacha20-poly1305 excludes the length field
if (padding < 4) padding += 8;
const packet = concat(u32(payload.length + padding + 1), Uint8Array.of(padding), payload, new Uint8Array(padding));
socket.send(tx ? tx.seal(packet) : packet);
}
async function readPacket() {
const head = await read(4);
const packet = concat(head, await read(rx ? rx.length(head) + 16 : reader(head).u32()));
const plain = rx ? rx.open(packet) : packet.subarray(4);
return plain.subarray(1, plain.length - plain[0]);
}
/** Next packet, after handling the messages that may arrive at any time once the initial key exchange is done. */
async function receive(): Promise<Uint8Array> {
for (;;) {
const p = await readPacket();
if (!rx) return p; // strict kex: nothing else is allowed during the initial key exchange
const r = reader(p, 1);
switch (p[0]) {
case 1: r.u32(); throw new Error(`disconnected: ${r.str()}`);
case 80: r.str(); if (r.u8()) send(Uint8Array.of(82)); break; // global request: reply failure
case 2: case 3: case 4: case 53: break; // ignore, unimplemented, debug, auth banner
default: return p;
}
}
}
async function expect(type: number) {
const p = await receive();
if (p[0] !== type) throw new Error(`expected message ${type}, got ${p[0]}`);
return p;
}
let channel: number | undefined;
let remoteWindow = 0;
let remoteMaxPacket = 0;
let pending = new Uint8Array(0);
function flush() {
while (pending.length && remoteWindow) {
const n = Math.min(pending.length, remoteWindow, remoteMaxPacket);
send(msg(94, channel!, pending.subarray(0, n)));
pending = pending.subarray(n);
remoteWindow -= n;
}
}
async function run() {
let line;
do {
line = '';
while (!line.endsWith('\n')) line += String.fromCharCode((await read(1))[0]);
} while (!line.startsWith('SSH-'));
const serverVersion = line.trimEnd();
const clientVersion = 'SSH-2.0-web';
socket.send(enc.encode(`${clientVersion}\r\n`));
let sessionId: Uint8Array | undefined;
/** The initial key exchange, or a re-key started by the server's KEXINIT. */
async function kex(serverKexinit?: Uint8Array) {
held = [];
const clientKexinit = concat(Uint8Array.of(20), randomBytes(16), pack(
'curve25519-sha256,kex-strict-c-v00@openssh.com', 'ssh-ed25519',
CIPHER, CIPHER, '', '', 'none', 'none', '', '', false, 0,
));
send(clientKexinit);
serverKexinit ??= await expect(20);
if (!sessionId && !reader(serverKexinit, 17).str().split(',').includes('kex-strict-s-v00@openssh.com')) throw new Error('server lacks strict kex');
const ephemeral = x25519.keygen();
send(msg(30, ephemeral.publicKey));
const reply = reader(await expect(31), 1);
const hostKey = reply.bytes();
const serverPublic = reply.bytes();
const signature = reply.bytes();
const fingerprint = `SHA256:${btoa(String.fromCharCode(...sha256(hostKey))).replace(/=+$/, '')}`;
if (fingerprint !== o.hostKey) throw new Error(`unexpected host key ${fingerprint}`);
const K = mpint(x25519.getSharedSecret(ephemeral.secretKey, serverPublic));
const H = sha256(concat(pack(clientVersion, serverVersion, clientKexinit, serverKexinit, hostKey, ephemeral.publicKey, serverPublic), K));
if (!ed25519.verify(signature.subarray(-64), H, hostKey.subarray(-32))) throw new Error('bad host key signature');
const id = (sessionId ??= H);
const key = (letter: string) => {
const k1 = sha256(concat(K, H, enc.encode(letter), id));
return concat(k1, sha256(concat(K, H, k1)));
};
send(Uint8Array.of(21));
tx = chachaPoly(key('C'));
await expect(21);
rx = chachaPoly(key('D'));
const messages = held;
held = undefined;
messages.forEach(send);
}
await kex();
send(msg(5, 'ssh-userauth'));
await expect(6);
const { publicKey, seed } = parsePrivateKey(o.privateKey);
const auth = msg(50, o.user, 'ssh-connection', 'publickey', true, 'ssh-ed25519', publicKey);
send(concat(auth, pack(pack('ssh-ed25519', ed25519.sign(concat(pack(sessionId!), auth), seed)))));
if ((await receive())[0] !== 52) throw new Error('authentication failed');
send(msg(90, 'session', 0, WINDOW, MAX_PACKET));
const confirmation = reader(await expect(91), 5);
channel = confirmation.u32();
remoteWindow = confirmation.u32();
remoteMaxPacket = confirmation.u32();
send(msg(98, channel, 'pty-req', true, o.term, o.cols, o.rows, 0, 0, '\0')); // no terminal modes, just TTY_OP_END
send(msg(98, channel, 'shell', true));
flush();
let window = WINDOW;
for (;;) {
const p = await receive();
const r = reader(p, 5); // everything from here on is for our only channel
switch (p[0]) {
case 20: await kex(p); break;
case 93: remoteWindow += r.u32(); flush(); break;
case 95: r.u32(); // extended data (stderr): skip the type code
// falls through
case 94: {
const data = r.bytes();
o.onData(data);
if ((window -= data.length) <= WINDOW / 2) {
send(msg(93, channel, WINDOW - window));
window = WINDOW;
}
break;
}
case 98: r.str(); if (r.u8()) send(msg(100, channel)); break; // exit-status, keepalive, ...
case 100: throw new Error('pty or shell request failed');
case 97: send(msg(97, channel)); return;
}
}
}
run().catch((e) => e).then((error) => { socket.close(); o.onClose(error); });
return {
write(data) {
pending = concat(pending, typeof data === 'string' ? enc.encode(data) : data);
flush();
},
resize(cols, rows) {
o.cols = cols;
o.rows = rows;
if (channel !== undefined) send(msg(98, channel, 'window-change', false, cols, rows, 0, 0));
},
};
}

129
src/scripts/terminal.ts Normal file
View file

@ -0,0 +1,129 @@
// The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent.
import { Terminal, type IMarker } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import css from '@xterm/xterm/css/xterm.css?inline';
import { connect } from './ssh';
const SERVER = 'wss://web.nowhere.wirs.ing';
const HOST_KEY = 'SHA256:aLNZKHCfhvbGYnjghKsOLPrOWPVWwCsIPCuTb5f9J6A';
const SCROLLBACK = 10000;
/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */
export async function start(prompt: HTMLElement, command: string) {
const privateKey = await (await fetch('root_key')).text();
document.head.append(Object.assign(document.createElement('style'), { textContent: css }));
// The page is the scrollback: lines that scroll off the terminal's screen continue as page text above it,
// and the screen only grows to the rows in use, up to the window height.
const history = document.createElement('div');
history.className = 'history';
const screen = document.createElement('div');
screen.className = 'screen';
const element = screen.appendChild(document.createElement('div'));
prompt.after(history, screen);
const { color, fontFamily, fontSize } = getComputedStyle(screen);
const term = new Terminal({
fontFamily,
fontSize: parseFloat(fontSize),
scrollback: SCROLLBACK,
cursorBlink: !matchMedia('(prefers-reduced-motion: reduce)').matches,
cursorInactiveStyle: 'block', // typing anywhere on the page reaches the terminal, focused or not
theme: { background: '#00000000', foreground: color, cursor: color, green: color }, // xterm.js rejects 'transparent'
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(element);
fit.fit();
// The wheel scrolls the page, except in full-screen apps, where xterm.js turns it into arrow keys.
screen.addEventListener('wheel', (e) => term.buffer.active.type === 'normal' && e.stopPropagation(), { capture: true });
const grid = element.querySelector('.xterm-screen')!;
let used = 1; // screen rows in use, like a terminal window filling up
let moved: IMarker | undefined; // the last scrollback line already moved into the page
function layout() {
const buffer = term.buffer.active;
const rowHeight = grid.clientHeight / term.rows;
if (buffer.type === 'normal') {
used = buffer.baseY ? term.rows : Math.max(used, buffer.cursorY + 1);
const last = moved && !moved.isDisposed ? moved.line : -1;
for (let y = last; y >= buffer.baseY; y--) history.lastChild!.remove(); // pulled back onto a taller screen
for (let y = last + 1; y < buffer.baseY; y++) history.append(`${buffer.getLine(y)!.translateToString(true)}\n`);
while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove();
if (last !== buffer.baseY - 1) {
moved?.dispose();
moved = buffer.baseY ? term.registerMarker(-1 - buffer.cursorY) : undefined;
}
}
history.style.lineHeight = `${rowHeight}px`;
screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`;
}
// "Erase scrollback" (sent by `clear`) erases it from the page too.
term.parser.registerCsiHandler({ final: 'J' }, (params) => (params[0] === 3 && history.replaceChildren(), false));
// Like a terminal window, the page scrolls along as output reaches the bottom, unless the visitor scrolled away.
const cursorRow = () => {
const { top, height } = grid.getBoundingClientRect();
const rowHeight = height / term.rows;
return { top: top + term.buffer.active.cursorY * rowHeight, bottom: top + (term.buffer.active.cursorY + 1) * rowHeight };
};
const reveal = () =>
term.buffer.active.type === 'alternate'
? screen.scrollIntoView({ block: 'nearest' }) // full-screen apps are shown whole
: scrollBy(0, Math.max(0, Math.ceil(cursorRow().bottom - innerHeight))); // whole pixels, or it stays a fraction below
let follow = true;
addEventListener('scroll', () => (follow = cursorRow().top < innerHeight), { passive: true });
term.onWriteParsed(() => {
layout();
if (follow) reveal();
});
term.onResize(layout);
new ResizeObserver(() => {
fit.fit();
if (follow) reveal();
}).observe(element);
layout();
reveal();
term.focus();
// The page's prompt stands in for the shell's first prompt, so that prompt and the shell's echo of the command are dropped.
let phase: 'starting' | 'echo' | 'live' = 'starting';
let typeahead = '';
let idle: ReturnType<typeof setTimeout>;
const session = connect({
socket: new WebSocket(SERVER),
user: 'root',
privateKey,
hostKey: HOST_KEY,
term: 'xterm-256color',
cols: term.cols,
rows: term.rows,
onData(data) {
if (phase === 'live') return term.write(data);
if (phase === 'echo') {
const newline = data.indexOf(10);
if (newline >= 0) {
phase = 'live';
term.write(data.subarray(newline + 1));
}
return;
}
// The shell is ready once its first output has settled; a command sent earlier would be echoed twice.
clearTimeout(idle);
idle = setTimeout(() => {
phase = 'echo';
session.write(`${command}\r${typeahead}`);
}, 300);
},
onClose(error) {
term.write(`${term.buffer.active.cursorX ? '\r\n' : ''}[${error?.message ?? 'connection closed'}]\r\n`);
},
});
term.onData((data) => {
reveal();
if (phase === 'starting') typeahead += data;
else session.write(data);
});
term.onBinary((data) => session.write(Uint8Array.from(data, (c) => c.charCodeAt(0)))); // mouse reports in the default encoding
term.onResize(({ cols, rows }) => session.resize(cols, rows));
return term;
}

View file

@ -54,7 +54,7 @@ a:hover, a:focus-visible {
}
/* Prompt */
.prompt { color: var(--dim); }
.prompt { color: var(--dim); white-space: pre-wrap; }
.prompt b { color: var(--fg); font-weight: normal; }
.cursor {
display: inline-block;
@ -64,6 +64,20 @@ a:hover, a:focus-visible {
animation: blink 1.1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
#term-input { all: unset; width: 0; padding-right: 1px; caret-color: var(--fg); } /* as wide as its text */
.prompt:focus-within .cursor { display: none; } /* the input's own caret takes over while typing */
/* Terminal: continues right below the first prompt. Lines scrolled off its screen become page text,
the screen shows the rows in use and xterm.js fits rows and columns to the window. */
.prompt:has(+ .history) { margin-bottom: 0; }
.history { white-space: pre-wrap; }
.screen { overflow: clip; }
.screen > div { height: 100dvh; }
.screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */
@media (prefers-reduced-motion: no-preference) {
/* typing still reaches the terminal when it is not focused, so its cursor keeps blinking like the page's */
.screen .xterm-rows:not(.xterm-focus) .xterm-cursor-block { animation: blink 1.1s steps(1) infinite; }
}
/* Header */
header {