Keep colors and styles of lines moved into the page

Lines scrolling off the terminal screen are converted from xterm.js's
cells to styled page text: palette, 256 and true colors, background,
bold (bright), italic, dim, underline, strikethrough, inverse and
invisible, without trailing spaces and keeping empty lines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zebreus 2026-10-25 02:01:21 +02:00
commit fc250b31b6
2 changed files with 50 additions and 2 deletions

View file

@ -1,5 +1,5 @@
// 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 { Terminal, type IBufferLine, type IMarker } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import css from '@xterm/xterm/css/xterm.css?inline';
import { connect } from './ssh';
@ -7,6 +7,10 @@ import { connect } from './ssh';
const SERVER = 'wss://web.nowhere.wirs.ing';
const HOST_KEY = 'SHA256:aLNZKHCfhvbGYnjghKsOLPrOWPVWwCsIPCuTb5f9J6A';
const SCROLLBACK = 10000;
// xterm.js's default colors, to color lines moved into the page like the terminal does.
const ANSI = ['#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf',
'#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec'];
const CUBE = [0, 95, 135, 175, 215, 255];
const boringData = 'LS0tLS1PUlRWQSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0KbzNPeW9hQW1uUDFlTUt4Z3F3Uk5OTk5OT1Q1aW96SE5OTk5Sb3o5aE1ETk5OTk5OTk5OT05OTk5aak5OTk5nbXAydGdNSgpEbEFHSGtCRE5OTlBQRDhVdU8vVDRKRE8xSnRVYUxIMUQyOWIvNlFPVzk3NWlyOERmTmZDRmppRE5OTlh3eVh2UmU1RmJ1ClhqTk5OTmdtcDJ0Z01KRGxBR0hrQkROTk5QUEQ4VXVPL1Q0SkRPMUp0VWFMSDFEMjliLzZRT1c5NzVpcjhEZk5mQ0ZqaUQKTk5OUk44eXRHaFIrMTlMTkVoeHo3cmQyU00xQ2lIb1IrYmpsQk5SQjVWb1BhaWFXUWpyUlU4b3VNTlVJbk5ycXVHSVFvMgp3L2JaUmEzaXo5N2tQalBqOVlQOU5OTk5WS1dpbzNFTm96OTNuVElsTUY1M25LV21ZenloTWxPd3JKcTNuSjRnWndWbFp0ClJQTmpEPQotLS0tLVJBUSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0K';
/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */
@ -47,7 +51,7 @@ export function start(prompt: HTMLElement, command: string) {
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`);
for (let y = last + 1; y < buffer.baseY; y++) history.append(pageLine(buffer.getLine(y)!));
while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove();
if (last !== buffer.baseY - 1) {
moved?.dispose();
@ -57,6 +61,49 @@ export function start(prompt: HTMLElement, command: string) {
history.style.lineHeight = `${rowHeight}px`;
screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`;
}
// A line as page text, with the colors and styles the terminal shows it in.
const palette = ANSI.map((c, i) => (i === 2 ? color : c)); // green is the page's, as in the theme
const colorOf = (n: number, rgb: boolean) => {
if (rgb) return `#${n.toString(16).padStart(6, '0')}`;
if (n < 16) return palette[n];
if (n < 232) return `rgb(${CUBE[Math.floor((n - 16) / 36)]},${CUBE[Math.floor((n - 16) / 6) % 6]},${CUBE[(n - 16) % 6]})`;
const gray = 8 + (n - 232) * 10;
return `rgb(${gray},${gray},${gray})`;
};
const cell = term.buffer.normal.getNullCell();
function pageLine(line: IBufferLine) {
const row = document.createElement('div');
let end = line.length; // up to the last visible cell, so copied text has no trailing spaces
while (end && !line.getCell(end - 1, cell)!.getChars().trim() && cell.isBgDefault() && !cell.isInverse()) end--;
let span: HTMLElement | undefined;
let current: string | undefined;
for (let x = 0; x < end; x++) {
line.getCell(x, cell);
if (!cell.getWidth()) continue; // the second half of a wide character
const fgIndex = cell.getFgColor() + (cell.isBold() && cell.isFgPalette() && cell.getFgColor() < 8 ? 8 : 0); // bold is bright
let fg = cell.isFgDefault() ? '' : colorOf(fgIndex, cell.isFgRGB());
let bg = cell.isBgDefault() ? '' : colorOf(cell.getBgColor(), cell.isBgRGB());
if (cell.isInverse()) [fg, bg] = [bg || '#000', fg || color];
if (cell.isDim()) fg = `color-mix(in srgb, ${fg || color} 50%, transparent)`;
const decoration = [cell.isUnderline() && 'underline', cell.isStrikethrough() && 'line-through'].filter(Boolean).join(' ');
const style = [
fg && `color:${fg}`,
bg && `background:${bg}`,
cell.isBold() && 'font-weight:bold',
cell.isItalic() && 'font-style:italic',
decoration && `text-decoration:${decoration}`,
cell.isInvisible() && 'visibility:hidden',
].filter(Boolean).join(';');
if (style !== current) {
span = row.appendChild(document.createElement('span'));
span.style.cssText = current = style;
}
span!.textContent += cell.getChars() || ' ';
}
return row;
}
// "Erase scrollback" (sent by `clear`) erases it from the page too.
term.parser.registerCsiHandler({ final: 'J' }, (params) => (params[0] === 3 && history.replaceChildren(), false));

View file

@ -71,6 +71,7 @@ a:hover, a:focus-visible {
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; }
.history > div { min-height: 1lh; } /* keeps empty lines */
.screen { overflow: clip; }
.screen > div { height: 100dvh; }
.screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */