Clickable links, full-width terminal and exact page history

- OSC 8 hyperlinks and plain URLs are clickable on the screen and in the
  page history. Links to a section of the current page scroll there,
  others open in a new tab. Links show their target on hover and invert
  in their own color, like the page's links.
- The terminal fills the page column (the fit addon kept room for a
  hidden scrollbar).
- Lines in the page history sit exactly on the terminal's cell grid,
  also for wide and fallback-font characters, and no longer wrap.
- The first command is sent as soon as the shell's line editor is ready,
  instead of guessing from a pause in its output.

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

View file

@ -1,6 +1,6 @@
// The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent.
import { Terminal, type IBufferLine, type IMarker } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { Terminal, type IBufferCell, type IBufferLine, type IMarker } from '@xterm/xterm';
import { WebLinksAddon } from '@xterm/addon-web-links';
import css from '@xterm/xterm/css/xterm.css?inline';
import { connect } from './ssh';
@ -11,6 +11,8 @@ const SCROLLBACK = 10000;
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];
// URLs that become links, on the screen and in the page (the web-links addon's default pattern).
const URL_PATTERN = /(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;
const boringData = 'LS0tLS1PUlRWQSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0KbzNPeW9hQW1uUDFlTUt4Z3F3Uk5OTk5OT1Q1aW96SE5OTk5Sb3o5aE1ETk5OTk5OTk5OT05OTk5aak5OTk5nbXAydGdNSgpEbEFHSGtCRE5OTlBQRDhVdU8vVDRKRE8xSnRVYUxIMUQyOWIvNlFPVzk3NWlyOERmTmZDRmppRE5OTlh3eVh2UmU1RmJ1ClhqTk5OTmdtcDJ0Z01KRGxBR0hrQkROTk5QUEQ4VXVPL1Q0SkRPMUp0VWFMSDFEMjliLzZRT1c5NzVpcjhEZk5mQ0ZqaUQKTk5OUk44eXRHaFIrMTlMTkVoeHo3cmQyU00xQ2lIb1IrYmpsQk5SQjVWb1BhaWFXUWpyUlU4b3VNTlVJbk5ycXVHSVFvMgp3L2JaUmEzaXo5N2tQalBqOVlQOU5OTk5WS1dpbzNFTm96OTNuVElsTUY1M25LV21ZenloTWxPd3JKcTNuSjRnWndWbFp0ClJQTmpEPQotLS0tLVJBUSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0K';
/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */
@ -25,6 +27,22 @@ export function start(prompt: HTMLElement, command: string) {
const element = screen.appendChild(document.createElement('div'));
prompt.after(history, screen);
// Links to a section of this very page scroll there, all others open in a new tab.
// Hovering shows where a link leads (and inverts it, see style.css).
const page = (url: URL | Location) => url.origin + url.pathname.replace(/index\.html$/, '');
const section = (uri: string) => {
const url = new URL(uri, location.href);
return url.hash && page(url) === page(location) ? url.hash : undefined;
};
const link = {
activate(_: MouseEvent, uri: string) {
const hash = section(uri);
if (hash) location.href = hash;
else open(uri, '_blank', 'noopener');
},
hover: (_: MouseEvent, uri: string) => (element.title = uri),
leave: () => element.removeAttribute('title'),
};
const { color, fontFamily, fontSize } = getComputedStyle(screen);
const term = new Terminal({
fontFamily,
@ -33,35 +51,74 @@ export function start(prompt: HTMLElement, command: string) {
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'
linkHandler: link, // OSC 8 hyperlinks
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new WebLinksAddon(link.activate, { urlRegex: URL_PATTERN, hover: link.hover, leave: link.leave }));
term.open(element);
fit.fit();
const grid = element.querySelector('.xterm-screen')!;
const rows = element.querySelector<HTMLElement>('.xterm-rows')!;
// Rows and columns fill the element, without the fit addon's room for a scrollbar, which is hidden here.
const fit = () => {
const cell = grid.getBoundingClientRect();
const { width, height } = element.getBoundingClientRect();
term.resize(Math.floor(width / (cell.width / term.cols)), Math.floor(height / (cell.height / term.rows)));
};
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')!;
// How wide characters are in the page's font, to space them on the cell grid exactly like xterm.js does.
const measure = screen.appendChild(document.createElement('span'));
measure.style.cssText = 'position:absolute;visibility:hidden;letter-spacing:0;white-space:pre';
const advances = new Map<string, number>();
const advance = (chars: string) => {
if (!advances.has(chars)) {
measure.textContent = chars.repeat(10);
advances.set(chars, measure.getBoundingClientRect().width / 10);
}
return advances.get(chars)!;
};
let cellWidth = 0;
let spacing = 0; // xterm.js's letter-spacing for characters as wide as 'W'
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;
cellWidth = grid.getBoundingClientRect().width / term.cols;
spacing = parseFloat(rows.style.letterSpacing) || 0;
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(pageLine(buffer.getLine(y)!));
for (let y = last + 1; y < buffer.baseY; y++) history.append(pageLine(buffer.getLine(y)!, y));
while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove();
if (last !== buffer.baseY - 1) {
moved?.dispose();
moved = buffer.baseY ? term.registerMarker(-1 - buffer.cursorY) : undefined;
}
hyperlinks = hyperlinks.filter((l) => !l.start.marker.isDisposed); // gone with their lines
}
history.style.lineHeight = `${rowHeight}px`;
history.style.letterSpacing = `${spacing}px`;
screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`;
}
// OSC 8 hyperlinks are remembered by position, so they stay links when their lines move into the page.
type Position = { marker: IMarker; x: number };
let hyperlinks: { uri: string; start: Position; end?: Position }[] = [];
term.parser.registerOscHandler(8, (data) => {
if (term.buffer.active.type === 'normal') {
const here = () => ({ marker: term.registerMarker(0), x: term.buffer.active.cursorX });
const open = hyperlinks.at(-1);
if (open && !open.end) open.end = here(); // a link ends where the next one starts or the closing sequence is
const uri = data.slice(data.indexOf(';') + 1);
if (/^https?:\/\//i.test(uri)) hyperlinks.push({ uri, start: here() }); // only web links, as on the screen
}
return false; // xterm.js still handles the link on the screen
});
// 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) => {
@ -72,8 +129,17 @@ export function start(prompt: HTMLElement, command: string) {
return `rgb(${gray},${gray},${gray})`;
};
const cell = term.buffer.normal.getNullCell();
function pageLine(line: IBufferLine) {
const foreground = (c: IBufferCell) => // '' for the default color; bold is bright
c.isFgDefault() ? '' : colorOf(c.getFgColor() + (c.isBold() && c.isFgPalette() && c.getFgColor() < 8 ? 8 : 0), c.isFgRGB());
const anchor = (uri: string, text = '') => {
const hash = section(uri);
return Object.assign(document.createElement('a'), { href: hash ?? uri, textContent: text }, hash ? {} : { target: '_blank', rel: 'noopener' });
};
function pageLine(line: IBufferLine, y: number) {
const row = document.createElement('div');
const linked = hyperlinks // the OSC 8 links on this line, as ranges of cells
.filter(({ start, end }) => start.marker.line <= y && (!end || end.marker.line >= y))
.map(({ uri, start, end }) => ({ uri, from: start.marker.line === y ? start.x : 0, to: end?.marker.line === y ? end.x : Infinity }));
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;
@ -81,25 +147,40 @@ export function start(prompt: HTMLElement, command: string) {
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 fg = foreground(cell);
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 uri = linked.find(({ from, to }) => x >= from && x < to)?.uri;
const chars = cell.getChars() || ' ';
const own = spacing + cellWidth * (cell.getWidth() - 1) + advance('W') - advance(chars); // e.g. wide or fallback-font characters
const style = [
fg && `color:${fg}`,
fg && `--fg:${fg}`, // the page's color variable, so links invert in their own color on hover
bg && `background:${bg}`,
cell.isBold() && 'font-weight:bold',
cell.isItalic() && 'font-style:italic',
decoration && `text-decoration:${decoration}`,
decoration && `text-decoration:${decoration}${uri ? ' dashed' : ''}`, // xterm.js underlines OSC 8 links dashed
cell.isInvisible() && 'visibility:hidden',
Math.abs(own - spacing) > 0.01 && `letter-spacing:${own}px`,
].filter(Boolean).join(';');
if (style !== current) {
span = row.appendChild(document.createElement('span'));
span.style.cssText = current = style;
if (`${style} ${uri}` !== current) {
span = row.appendChild(uri ? anchor(uri) : document.createElement('span'));
span.style.cssText = style;
current = `${style} ${uri}`;
}
span!.textContent += cell.getChars() || ' ';
span!.textContent += chars;
}
// Plain URLs, as the web-links addon finds them on the screen.
for (const span of row.querySelectorAll('span')) {
const text = span.textContent!;
const parts: (string | Node)[] = [];
let i = 0;
for (const match of text.matchAll(new RegExp(URL_PATTERN, 'g'))) {
parts.push(text.slice(i, match.index), anchor(match[0], match[0]));
i = match.index + match[0].length;
}
if (parts.length) span.replaceChildren(...parts, text.slice(i));
}
return row;
}
@ -125,7 +206,7 @@ export function start(prompt: HTMLElement, command: string) {
});
term.onResize(layout);
new ResizeObserver(() => {
fit.fit();
fit();
if (follow) reveal();
}).observe(element);
layout();
@ -157,12 +238,14 @@ export function start(prompt: HTMLElement, command: string) {
}
return;
}
// The shell is ready once its first output has settled; a command sent earlier would be echoed twice.
// The shell is ready once its line editor turns on bracketed paste, or else once its output has settled;
// a command sent earlier would be echoed twice.
const ready = new TextDecoder().decode(data).includes('\x1b[?2004h');
clearTimeout(idle);
idle = setTimeout(() => {
phase = 'echo';
session.write(`${command}\r${typeahead}`);
}, 300);
}, ready ? 0 : 1000);
},
onClose(error) {
term.write(`${term.buffer.active.cursorX ? '\r\n' : ''}[${error?.message ?? 'connection closed'}]\r\n`);

View file

@ -69,11 +69,14 @@ a:hover, a:focus-visible {
/* 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; }
.history { white-space: pre; overflow-x: clip; } /* rows of the terminal's width; cut, not wrapped, if the window narrows */
.history > div { min-height: 1lh; } /* keeps empty lines */
.history span { color: var(--fg); }
.screen { overflow: clip; }
.screen > div { height: 100dvh; }
.screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */
/* hovered links (xterm.js underlines them inline) invert in their own color, like the page's links */
.screen .xterm-rows span[style*="text-decoration: underline"] { background-color: currentColor; -webkit-text-fill-color: #000; text-shadow: none; }
@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; }