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:
parent
f106ffe22d
commit
aed4af0868
4 changed files with 112 additions and 26 deletions
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -9,7 +9,7 @@
|
|||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"astro": "^7.4.2"
|
||||
}
|
||||
|
|
@ -1526,10 +1526,10 @@
|
|||
"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==",
|
||||
"node_modules/@xterm/addon-web-links": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
|
||||
"integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"@noble/ciphers": "^2.4.0",
|
||||
"@noble/curves": "^2.4.0",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"astro": "^7.4.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue