|
|
@@ -25,10 +25,144 @@
|
|
|
}
|
|
|
});
|
|
|
|
|
|
+ // ANSI-to-HTML for the raw PTY stream (B8): SGR sequences
|
|
|
+ // become styled spans (bold, italic, 8/16/256-colour fg+bg,
|
|
|
+ // reset); every other CSI code is stripped. Chunks may split
|
|
|
+ // an escape sequence mid-code, so partial sequences carry
|
|
|
+ // across events in `carry`. Falls back to plain text when
|
|
|
+ // anything unexpected happens.
|
|
|
+ function makeAnsiRenderer() {
|
|
|
+ var carry = '';
|
|
|
+ var state = null; // {bold, italic, fg, bg}
|
|
|
+ var CSI = /\x1b\[([0-9;:]*)([A-Za-z])/;
|
|
|
+
|
|
|
+ var BASE = ['#000000', '#800000', '#008000', '#808000',
|
|
|
+ '#000080', '#800080', '#008080', '#c0c0c0',
|
|
|
+ '#808080', '#ff0000', '#00ff00', '#ffff00',
|
|
|
+ '#0000ff', '#ff00ff', '#00ffff', '#ffffff'];
|
|
|
+ var LEVELS = [0, 95, 135, 175, 215, 255];
|
|
|
+ function colour256(n) {
|
|
|
+ if (n < 16) return BASE[n];
|
|
|
+ if (n < 232) {
|
|
|
+ var cube = n - 16;
|
|
|
+ return 'rgb(' + LEVELS[(cube / 36 | 0) % 6] + ','
|
|
|
+ + LEVELS[(cube / 6 | 0) % 6] + ','
|
|
|
+ + LEVELS[cube % 6] + ')';
|
|
|
+ }
|
|
|
+ var grey = 8 + 10 * (n - 232);
|
|
|
+ return 'rgb(' + grey + ',' + grey + ',' + grey + ')';
|
|
|
+ }
|
|
|
+
|
|
|
+ function appendStyled(container, text) {
|
|
|
+ if (!state || text.length === 0) {
|
|
|
+ container.appendChild(document.createTextNode(text));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ var span = document.createElement('span');
|
|
|
+ if (state.bold) span.style.fontWeight = 'bold';
|
|
|
+ if (state.italic) span.style.fontStyle = 'italic';
|
|
|
+ if (state.fg != null) span.style.color = colour256(state.fg);
|
|
|
+ if (state.bg != null) span.style.backgroundColor = colour256(state.bg);
|
|
|
+ span.textContent = text;
|
|
|
+ container.appendChild(span);
|
|
|
+ }
|
|
|
+
|
|
|
+ function applySgr(params) {
|
|
|
+ var parts = params.split(';');
|
|
|
+ for (var i = 0; i < parts.length; i++) {
|
|
|
+ var inner = parts[i].split(':');
|
|
|
+ for (var j = 0; j < inner.length; j++) {
|
|
|
+ if (inner[j] === '') inner[j] = '0';
|
|
|
+ }
|
|
|
+ var n = parseInt(inner[0], 10);
|
|
|
+ if (isNaN(n)) continue;
|
|
|
+ if (n === 0) {
|
|
|
+ state = null;
|
|
|
+ } else if (n === 1) {
|
|
|
+ state = state || {};
|
|
|
+ state.bold = true;
|
|
|
+ } else if (n === 3) {
|
|
|
+ state = state || {};
|
|
|
+ state.italic = true;
|
|
|
+ } else if (n === 22) {
|
|
|
+ if (state) state.bold = false;
|
|
|
+ } else if (n === 23) {
|
|
|
+ if (state) state.italic = false;
|
|
|
+ } else if (n >= 30 && n <= 37) {
|
|
|
+ state = state || {};
|
|
|
+ state.fg = n - 30;
|
|
|
+ } else if (n === 39) {
|
|
|
+ if (state) state.fg = null;
|
|
|
+ } else if (n >= 40 && n <= 47) {
|
|
|
+ state = state || {};
|
|
|
+ state.bg = n - 40;
|
|
|
+ } else if (n === 49) {
|
|
|
+ if (state) state.bg = null;
|
|
|
+ } else if (n >= 90 && n <= 97) {
|
|
|
+ state = state || {};
|
|
|
+ state.fg = n - 90 + 8;
|
|
|
+ } else if (n >= 100 && n <= 107) {
|
|
|
+ state = state || {};
|
|
|
+ state.bg = n - 100 + 8;
|
|
|
+ } else if (n === 38 || n === 48) {
|
|
|
+ var mode = inner[1] !== undefined ? parseInt(inner[1], 10)
|
|
|
+ : (parseInt(parts[i + 1], 10) || 5);
|
|
|
+ var value = inner[2] !== undefined ? parseInt(inner[2], 10)
|
|
|
+ : parseInt(parts[i + 2], 10);
|
|
|
+ if (mode === 5 && !isNaN(value) && value >= 0 && value <= 255) {
|
|
|
+ state = state || {};
|
|
|
+ if (n === 38) state.fg = value; else state.bg = value;
|
|
|
+ i += 2;
|
|
|
+ } else if (mode === 2) {
|
|
|
+ i += 4; // truecolor: skip r;g;b unstyled
|
|
|
+ } else {
|
|
|
+ i += 2; // unsupported form: skip its args
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ render: function (text, container) {
|
|
|
+ try {
|
|
|
+ carry += text;
|
|
|
+ while (carry.length > 0) {
|
|
|
+ if (carry.charAt(0) !== '\x1b') {
|
|
|
+ var next = carry.indexOf('\x1b');
|
|
|
+ var plain = next < 0 ? carry : carry.substring(0, next);
|
|
|
+ carry = next < 0 ? '' : carry.substring(next);
|
|
|
+ appendStyled(container, plain);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ var match = CSI.exec(carry);
|
|
|
+ if (match === null) {
|
|
|
+ // Partial sequence: wait for the next
|
|
|
+ // chunk — unless it clearly ran away.
|
|
|
+ if (carry.length > 32) {
|
|
|
+ carry = carry.substring(1);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (match[2] === 'm') {
|
|
|
+ applySgr(match[1]);
|
|
|
+ } // every other CSI code: stripped
|
|
|
+ carry = carry.substring(match.index + match[0].length);
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ // Graceful fallback: never lose the bytes.
|
|
|
+ container.appendChild(document.createTextNode(text));
|
|
|
+ carry = '';
|
|
|
+ state = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
function connect(sessionId, rawMode) {
|
|
|
connected = true;
|
|
|
var log = document.getElementById(rawMode ? 'term-raw' : 'term-log');
|
|
|
var source = new EventSource('/terminal-stream/' + sessionId);
|
|
|
+ var ansi = rawMode ? makeAnsiRenderer() : null;
|
|
|
var lastId = 0;
|
|
|
var lastSeq = 0;
|
|
|
source.onmessage = function (event) {
|
|
|
@@ -38,13 +172,13 @@
|
|
|
if (!entry) return;
|
|
|
if (rawMode) {
|
|
|
// Raw PTY log: one unstyled <pre> of concatenated
|
|
|
- // chunks, byte-exact as the daemon produced them.
|
|
|
+ // chunks, ANSI-styled client-side (SGR only).
|
|
|
if (entry.type !== 'raw' || entry.seq <= lastSeq) return;
|
|
|
lastSeq = entry.seq;
|
|
|
var ph = log.querySelector('.term-placeholder');
|
|
|
if (ph) ph.remove();
|
|
|
var near = log.scrollHeight - log.scrollTop - log.clientHeight < 48;
|
|
|
- log.appendChild(document.createTextNode(entry.text));
|
|
|
+ ansi.render(entry.text, log);
|
|
|
if (near) log.scrollTop = log.scrollHeight;
|
|
|
return;
|
|
|
}
|