From ec3f569edc110f6d23b3d25fe790c8b679451786 Mon Sep 17 00:00:00 2001 From: alex Date: Mon, 27 Jul 2026 09:33:12 +0900 Subject: [PATCH] Improve SSH terminal UI and input handling --- src/server.js | 24 ++++++++++++---------- src/terminal.js | 26 +++++++++++++++++++++--- src/tui.js | 53 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 13 deletions(-) create mode 100644 src/tui.js diff --git a/src/server.js b/src/server.js index 9a76be5..96cde12 100644 --- a/src/server.js +++ b/src/server.js @@ -5,8 +5,9 @@ import net from 'node:net'; import crypto from 'node:crypto'; import ssh2 from 'ssh2'; import { loadConfig, configPath } from './config.js'; -import { color, clear } from './terminal.js'; +import { color, clear, header, box } from './terminal.js'; import { createCommandRouter } from './commands.js'; +import { configureTerminal, createTuiSession } from './tui.js'; const { Server } = ssh2; @@ -19,16 +20,19 @@ if (!fs.existsSync(hostKey)) { fs.mkdirSync(path.dirname(hostKey), { recursive: function isAdmin(ctx) { return ctx.admin === true; } function count() { return sessions.size; } function sendNotification(message) { for (const socket of notifications) socket.write(JSON.stringify({ type: 'notification', message }) + '\n'); } -function render(ctx, body = '') { - const p = config.persona || {}; const primary = config.theme?.primary || 'cyan'; - const title = `● ${p.name || 'Your Name'} — ${p.tagline || ''}`; const users = `${count()} connected`; const gap = ' '.repeat(Math.max(2, (ctx.cols || 80) - title.length - users.length)); - return `${clear}${color(primary, title)}${gap}${color(config.theme?.muted || 'brightBlack', users)}\n${color(config.theme?.muted || 'brightBlack', p.location || '')}\n\n${body}\n\n${color(primary, 'portfolio')} ${isAdmin(ctx) ? color('yellow', '[admin]') : ''}> `; +function prompt(ctx) { return `${color(config.theme?.primary || 'cyan', 'portfolio')} ${isAdmin(ctx) ? color('yellow', '[admin] ') : ''}› `; } +function renderHome(ctx) { + const p = config.persona || {}; const width = Math.max(40, ctx.cols || 80); const title = `● ${p.name || 'Your Name'} — ${p.tagline || ''}`; + const content = router.home(ctx).split('\n'); + return `${clear}${header(title, `${count()} connected`, width, config.theme?.primary, config.theme?.muted)}\r\n${color(config.theme?.muted || 'brightBlack', p.location || '')}\r\n\r\n${box('Portfolio', content, width, config.theme)}\r\n\r\n`; } -const router = createCommandRouter({ config, isAdmin, ownerOnline: () => [...sessions].some(s => s.ctx.admin), sendNotification, reload: () => Object.assign(config, loadConfig()), broadcast: line => { for (const s of sessions) s.channel.write(`\r\n${color(config.theme?.accent || 'yellow', line)}\r\n` + render(s.ctx)); } }); +function refreshHeaders() { for (const s of sessions) s.tui?.updateHeader(header(`● ${config.persona?.name || 'Your Name'} — ${config.persona?.tagline || ''}`, `${count()} connected`, s.ctx.cols, config.theme?.primary, config.theme?.muted)); } +const router = createCommandRouter({ config, isAdmin, ownerOnline: () => [...sessions].some(s => s.ctx.admin), sendNotification, reload: () => Object.assign(config, loadConfig()), broadcast: line => { for (const s of sessions) s.tui?.notify(color(config.theme?.accent || 'yellow', line)); } }); function handleSession(channel, ctx) { - let buffer = ''; ctx.channel = channel; const active = { channel, ctx }; sessions.add(active); channel.write(render(ctx, router.home(ctx))); - channel.on('data', data => { for (const byte of data.toString()) { if (byte === '\r' || byte === '\n') { channel.write(`\r\n${router.execute(ctx, buffer)}\n` + render(ctx)); buffer = ''; } else if (byte === '\x7f') { if (buffer) { buffer = buffer.slice(0, -1); channel.write('\b \b'); } } else if (byte >= ' ') { buffer += byte; channel.write(byte); } } }); - channel.on('close', () => sessions.delete(active)); + ctx.channel = channel; const active = { channel, ctx, tui: null }; sessions.add(active); refreshHeaders(); + configureTerminal(channel, config.persona?.name || 'SSH Portfolio'); + active.tui = createTuiSession({ channel, ctx, router, prompt, renderHome, onClose: () => { sessions.delete(active); refreshHeaders(); } }); + active.tui.showHome(); } const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-portfolio' }, client => { const ctx = { admin: false, channel: null, cols: 80, username: 'visitor' }; @@ -41,7 +45,7 @@ const server = new Server({ hostKeys: [fs.readFileSync(hostKey)], ident: 'ssh-po if (allowed && (!ctxAuth.signature || ctxAuth.key.verify(ctxAuth.blob, ctxAuth.signature, ctxAuth.hashAlgo))) { ctx.admin = true; ctxAuth.accept(); return; } } ctxAuth.reject(['none', 'publickey']); - }).on('ready', () => client.on('session', accept => { const sshSession = accept(); sshSession.on('pty', (acceptPty, rejectPty, info) => { ctx.cols = info.cols || 80; acceptPty(); }); sshSession.on('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((router.execute(ctx, info.command) || '') + '\n'); e.exit(0); e.end(); }); })).on('error', () => {}); + }).on('ready', () => client.on('session', accept => { const sshSession = accept(); sshSession.on('pty', (acceptPty, rejectPty, info) => { ctx.cols = info.cols || 80; acceptPty(); sshSession.on('window-change', (acceptResize, rejectResize, resize) => { ctx.cols = resize.cols || ctx.cols; refreshHeaders(); }); }); sshSession.on('shell', (acceptShell) => handleSession(acceptShell(), ctx)); sshSession.on('exec', (acceptExec, rejectExec, info) => { const e = acceptExec(); e.write((router.execute(ctx, info.command) || '') + '\r\n'); e.exit(0); e.end(); }); })).on('error', () => {}); }); server.listen(config.host.port, '0.0.0.0', () => console.log(`SSH portfolio listening on port ${config.host.port}; config: ${configPath}`)); net.createServer(socket => { notifications.add(socket); socket.on('data', () => {}); socket.on('close', () => notifications.delete(socket)); }).listen(config.host.notificationPort, '127.0.0.1'); diff --git a/src/terminal.js b/src/terminal.js index e97e0ca..0d68c36 100644 --- a/src/terminal.js +++ b/src/terminal.js @@ -1,7 +1,27 @@ export const ESC = '\x1b['; export const colors = { cyan: 36, yellow: 33, green: 32, magenta: 35, blue: 34, white: 37, red: 31, brightBlack: 90 }; export const color = (name, text) => `\x1b[${colors[name] || colors.cyan}m${text}\x1b[0m`; -export const clear = '\x1b[2J\x1b[H'; -export function wrap(text, width = 78) { - const out = []; for (const paragraph of String(text).split('\n')) { let line = ''; for (const word of paragraph.split(/\s+/)) { if (line && line.length + word.length + 1 > width) { out.push(line); line = word; } else line += (line ? ' ' : '') + word; } out.push(line); } return out; +export const clear = '\x1b[0m\x1b[2J\x1b[3J\x1b[H'; +export const crlf = text => String(text).replace(/\r?\n/g, '\r\n'); +export const visibleLength = text => String(text).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').length; +export const truncate = (text, width) => Array.from(String(text)).slice(0, Math.max(0, width)).join(''); +export function header(left, right, width, leftColor = 'cyan', rightColor = 'brightBlack') { + const safeWidth = Math.max(30, width || 80); + const safeRight = truncate(right, Math.max(0, safeWidth - 2)); + const safeLeft = truncate(left, Math.max(1, safeWidth - safeRight.length - 2)); + return `${color(leftColor, safeLeft)}${' '.repeat(Math.max(1, safeWidth - safeLeft.length - safeRight.length))}${color(rightColor, safeRight)}`; +} +export function box(title, lines, width, theme = {}) { + const outer = Math.max(30, width || 80); const inner = outer - 4; + const primary = theme.primary || 'cyan'; const muted = theme.muted || 'brightBlack'; + const titleText = ` ${truncate(title, inner - 2)} `; const topFill = Math.max(0, outer - titleText.length - 2); + let output = `${color(muted, '╭─')}${color(primary, titleText)}${color(muted, `${'─'.repeat(topFill)}╮`)}\r\n`; + for (const source of lines) { + const wrapped = wrap(source, inner); + for (const line of wrapped) output += `${color(muted, '│')} ${line}${' '.repeat(Math.max(0, inner - visibleLength(line)))} ${color(muted, '│')}\r\n`; + } + return output + color(muted, `╰${'─'.repeat(outer - 2)}╯`); +} +export function wrap(text, width = 78) { + const out = []; for (const paragraph of String(text).split('\n')) { if (!paragraph) { out.push(''); continue; } let line = ''; for (const word of paragraph.split(/\s+/)) { if (line && visibleLength(line) + visibleLength(word) + 1 > width) { out.push(line); line = word; } else line += (line ? ' ' : '') + word; } out.push(line); } return out; } diff --git a/src/tui.js b/src/tui.js new file mode 100644 index 0000000..dbf7d1d --- /dev/null +++ b/src/tui.js @@ -0,0 +1,53 @@ +import { color, crlf } from './terminal.js'; + +export function configureTerminal(stream, title) { + stream.write('\x1b[?2004h\x1b[?7h\x1b[?25h\x1b[?1h\x1b='); + stream.write(`\x1b]2;${String(title).replace(/[\x00-\x1f\x7f]/g, '')}\x07`); +} + +export function createTuiSession({ channel, ctx, router, renderHome, prompt, onClose }) { + const state = { input: '', cursor: 0, history: [], historyIndex: 0, closed: false }; + const redrawInput = () => { + if (state.closed) return; + channel.write(`\r\x1b[2K${prompt(ctx)}${state.input}`); + if (state.cursor < state.input.length) channel.write(`\x1b[${state.input.length - state.cursor}D`); + }; + const insert = text => { + const clean = text.replace(/\x1b\[20[01]~/g, '').replace(/[\r\n]+/g, ' ').replace(/[\x00-\x1f\x7f-\x9f]/g, ''); + if (!clean) return; state.input = state.input.slice(0, state.cursor) + clean + state.input.slice(state.cursor); state.cursor += clean.length; redrawInput(); + }; + const showHome = () => { if (!state.closed) channel.write(renderHome(ctx) + prompt(ctx) + state.input); }; + const notify = text => { if (!state.closed) { channel.write(`\r\x1b[2K${crlf(text)}\r\n`); redrawInput(); } }; + const updateHeader = text => { if (!state.closed) channel.write(`\x1b7\x1b[1;1H\x1b[2K${text}\x1b8`); }; + const submit = () => { + const input = state.input.trim(); channel.write('\r\n'); + if (input) { if (state.history.at(-1) !== input) state.history.push(input); if (state.history.length > 100) state.history.shift(); } + state.historyIndex = state.history.length; state.input = ''; state.cursor = 0; + const name = input.split(/\s+/)[0]?.toLowerCase() || 'home'; + if (name === 'home' || name === 'clear') { router.execute(ctx, input); showHome(); return; } + const output = router.execute(ctx, input); if (state.closed || channel.destroyed) return; + if (output) channel.write(`${crlf(output)}\r\n`); channel.write(prompt(ctx)); + }; + const history = direction => { + if (!state.history.length) return; + state.historyIndex = Math.max(0, Math.min(state.history.length, state.historyIndex + direction)); + state.input = state.historyIndex === state.history.length ? '' : state.history[state.historyIndex]; state.cursor = state.input.length; redrawInput(); + }; + const move = delta => { const next = Math.max(0, Math.min(state.input.length, state.cursor + delta)); if (next !== state.cursor) { channel.write(delta < 0 ? `\x1b[${state.cursor - next}D` : `\x1b[${next - state.cursor}C`); state.cursor = next; } }; + channel.on('data', data => { + let key = data.toString(); + if (key.includes('\x1b[200~')) { insert(key); return; } + while (key.length) { + const sequence = ['\x1b[A', '\x1bOA', '\x1b[B', '\x1bOB', '\x1b[C', '\x1bOC', '\x1b[D', '\x1bOD'].find(item => key.startsWith(item)); + if (sequence) { if (sequence.endsWith('A')) history(-1); else if (sequence.endsWith('B')) history(1); else if (sequence.endsWith('C')) move(1); else move(-1); key = key.slice(sequence.length); continue; } + const char = key[0]; key = key.slice(1); + if (char === '\r' || char === '\n') { if (char === '\r' && key[0] === '\n') key = key.slice(1); submit(); } + else if (char === '\x03' || (char === '\x04' && !state.input)) channel.end(); + else if (char === '\x7f' || char === '\b') { if (state.cursor > 0) { state.input = state.input.slice(0, state.cursor - 1) + state.input.slice(state.cursor); state.cursor--; redrawInput(); } } + else if (char === '\x01') move(-state.cursor); else if (char === '\x05') move(state.input.length - state.cursor); + else if (char >= ' ') insert(char); + } + }); + channel.on('close', () => { state.closed = true; onClose?.(); }); + return { showHome, notify, redrawInput, updateHeader, state }; +}