Render ANSI log colors

This commit is contained in:
2026-06-24 15:42:44 +08:00
parent 285ebd4437
commit ad3cccb2eb
2 changed files with 351 additions and 3 deletions
+230 -3
View File
@@ -12,6 +12,44 @@ const autoScroll = document.querySelector('#autoScroll');
const pauseButton = document.querySelector('#pauseButton');
const clearButton = document.querySelector('#clearButton');
const ANSI_SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
const ANSI_FG_CLASSES = new Map([
[30, 'ansi-fg-black'],
[31, 'ansi-fg-red'],
[32, 'ansi-fg-green'],
[33, 'ansi-fg-yellow'],
[34, 'ansi-fg-blue'],
[35, 'ansi-fg-magenta'],
[36, 'ansi-fg-cyan'],
[37, 'ansi-fg-white'],
[90, 'ansi-fg-bright-black'],
[91, 'ansi-fg-bright-red'],
[92, 'ansi-fg-bright-green'],
[93, 'ansi-fg-bright-yellow'],
[94, 'ansi-fg-bright-blue'],
[95, 'ansi-fg-bright-magenta'],
[96, 'ansi-fg-bright-cyan'],
[97, 'ansi-fg-bright-white']
]);
const ANSI_BG_CLASSES = new Map([
[40, 'ansi-bg-black'],
[41, 'ansi-bg-red'],
[42, 'ansi-bg-green'],
[43, 'ansi-bg-yellow'],
[44, 'ansi-bg-blue'],
[45, 'ansi-bg-magenta'],
[46, 'ansi-bg-cyan'],
[47, 'ansi-bg-white'],
[100, 'ansi-bg-bright-black'],
[101, 'ansi-bg-bright-red'],
[102, 'ansi-bg-bright-green'],
[103, 'ansi-bg-bright-yellow'],
[104, 'ansi-bg-bright-blue'],
[105, 'ansi-bg-bright-magenta'],
[106, 'ansi-bg-bright-cyan'],
[107, 'ansi-bg-bright-white']
]);
let socket;
let reconnectTimer;
let paused = false;
@@ -45,6 +83,195 @@ function updateCounts() {
pendingCount.textContent = `${pendingChunks.length} buffered`;
}
function createAnsiState() {
return {
fgClass: null,
bgClass: null,
fgColor: null,
bgColor: null,
bold: false,
dim: false,
italic: false,
underline: false
};
}
function hasAnsiStyle(state) {
return Boolean(
state.fgClass ||
state.bgClass ||
state.fgColor ||
state.bgColor ||
state.bold ||
state.dim ||
state.italic ||
state.underline
);
}
function appendAnsiSegment(fragment, state, text) {
if (!text) {
return;
}
if (!hasAnsiStyle(state)) {
fragment.append(document.createTextNode(text));
return;
}
const span = document.createElement('span');
if (state.fgClass) span.classList.add(state.fgClass);
if (state.bgClass) span.classList.add(state.bgClass);
if (state.bold) span.classList.add('ansi-bold');
if (state.dim) span.classList.add('ansi-dim');
if (state.italic) span.classList.add('ansi-italic');
if (state.underline) span.classList.add('ansi-underline');
if (state.fgColor) span.style.color = state.fgColor;
if (state.bgColor) span.style.backgroundColor = state.bgColor;
span.textContent = text;
fragment.append(span);
}
function readExtendedAnsiColor(params, index) {
const mode = params[index + 1];
if (mode === 5 && Number.isInteger(params[index + 2])) {
return { color: ansi256ToCss(params[index + 2]), nextIndex: index + 2 };
}
if (
mode === 2 &&
Number.isInteger(params[index + 2]) &&
Number.isInteger(params[index + 3]) &&
Number.isInteger(params[index + 4])
) {
const r = clampColor(params[index + 2]);
const g = clampColor(params[index + 3]);
const b = clampColor(params[index + 4]);
return { color: `rgb(${r} ${g} ${b})`, nextIndex: index + 4 };
}
return { color: null, nextIndex: index };
}
function ansi256ToCss(value) {
const color = clampColor(value);
if (color < 16) {
const palette = [
'#000000',
'#cd3131',
'#0dbc79',
'#e5e510',
'#2472c8',
'#bc3fbc',
'#11a8cd',
'#e5e5e5',
'#666666',
'#f14c4c',
'#23d18b',
'#f5f543',
'#3b8eea',
'#d670d6',
'#29b8db',
'#ffffff'
];
return palette[color];
}
if (color >= 232) {
const level = 8 + (color - 232) * 10;
return `rgb(${level} ${level} ${level})`;
}
const adjusted = color - 16;
const r = Math.floor(adjusted / 36);
const g = Math.floor((adjusted % 36) / 6);
const b = adjusted % 6;
const component = (part) => (part === 0 ? 0 : 55 + part * 40);
return `rgb(${component(r)} ${component(g)} ${component(b)})`;
}
function clampColor(value) {
return Math.max(0, Math.min(255, value));
}
function applyAnsiCodes(state, rawCodes) {
const codes = rawCodes === '' ? [0] : rawCodes.split(';').map((code) => Number.parseInt(code || '0', 10));
for (let index = 0; index < codes.length; index += 1) {
const code = Number.isNaN(codes[index]) ? 0 : codes[index];
if (code === 0) {
Object.assign(state, createAnsiState());
} else if (code === 1) {
state.bold = true;
state.dim = false;
} else if (code === 2) {
state.dim = true;
state.bold = false;
} else if (code === 3) {
state.italic = true;
} else if (code === 4) {
state.underline = true;
} else if (code === 22) {
state.bold = false;
state.dim = false;
} else if (code === 23) {
state.italic = false;
} else if (code === 24) {
state.underline = false;
} else if (code === 38) {
const extended = readExtendedAnsiColor(codes, index);
state.fgClass = null;
state.fgColor = extended.color;
index = extended.nextIndex;
} else if (code === 39) {
state.fgClass = null;
state.fgColor = null;
} else if (code === 48) {
const extended = readExtendedAnsiColor(codes, index);
state.bgClass = null;
state.bgColor = extended.color;
index = extended.nextIndex;
} else if (code === 49) {
state.bgClass = null;
state.bgColor = null;
} else if (ANSI_FG_CLASSES.has(code)) {
state.fgClass = ANSI_FG_CLASSES.get(code);
state.fgColor = null;
} else if (ANSI_BG_CLASSES.has(code)) {
state.bgClass = ANSI_BG_CLASSES.get(code);
state.bgColor = null;
}
}
}
function renderAnsiText(text) {
const fragment = document.createDocumentFragment();
const state = createAnsiState();
let lastIndex = 0;
let match;
ANSI_SGR_PATTERN.lastIndex = 0;
while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) {
appendAnsiSegment(fragment, state, text.slice(lastIndex, match.index));
applyAnsiCodes(state, match[1]);
lastIndex = ANSI_SGR_PATTERN.lastIndex;
}
appendAnsiSegment(fragment, state, text.slice(lastIndex));
return fragment;
}
function renderLog() {
output.replaceChildren(renderAnsiText(visibleLines.join('\n')));
}
function appendText(text) {
if (!text) {
return;
@@ -65,7 +292,7 @@ function appendText(text) {
visibleLines = visibleLines.slice(-MAX_VISIBLE_LINES);
}
output.textContent = visibleLines.join('\n');
renderLog();
updateCounts();
if (shouldScroll) {
@@ -76,7 +303,7 @@ function appendText(text) {
function setSnapshot(lines) {
visibleLines = Array.isArray(lines) ? lines.slice(-MAX_VISIBLE_LINES) : [];
pendingChunks = [];
output.textContent = visibleLines.join('\n');
renderLog();
updateCounts();
if (autoScroll.checked) {
@@ -173,7 +400,7 @@ pauseButton.addEventListener('click', () => {
clearButton.addEventListener('click', () => {
visibleLines = [];
pendingChunks = [];
output.textContent = '';
renderLog();
updateCounts();
});
+121
View File
@@ -193,6 +193,127 @@ input:focus-visible {
content: "No log lines yet.";
}
.ansi-bold {
font-weight: 700;
}
.ansi-dim {
opacity: 0.72;
}
.ansi-italic {
font-style: italic;
}
.ansi-underline {
text-decoration: underline;
}
.ansi-fg-black {
color: #777d85;
}
.ansi-fg-red {
color: #e86f6f;
}
.ansi-fg-green {
color: #6fcf97;
}
.ansi-fg-yellow {
color: #d8b75f;
}
.ansi-fg-blue {
color: #7aa7e8;
}
.ansi-fg-magenta {
color: #cf8ee8;
}
.ansi-fg-cyan {
color: #68c8d3;
}
.ansi-fg-white {
color: #d7dbdf;
}
.ansi-fg-bright-black {
color: #9aa1aa;
}
.ansi-fg-bright-red {
color: #ff8a8a;
}
.ansi-fg-bright-green {
color: #8ee6ad;
}
.ansi-fg-bright-yellow {
color: #f0d47b;
}
.ansi-fg-bright-blue {
color: #9dc1ff;
}
.ansi-fg-bright-magenta {
color: #e5a6ff;
}
.ansi-fg-bright-cyan {
color: #8fe6ef;
}
.ansi-fg-bright-white {
color: #ffffff;
}
.ansi-bg-black,
.ansi-bg-bright-black {
background-color: #25282c;
}
.ansi-bg-red,
.ansi-bg-bright-red {
background-color: #5c2323;
}
.ansi-bg-green,
.ansi-bg-bright-green {
background-color: #1f4a35;
}
.ansi-bg-yellow,
.ansi-bg-bright-yellow {
background-color: #4f4120;
}
.ansi-bg-blue,
.ansi-bg-bright-blue {
background-color: #203c5f;
}
.ansi-bg-magenta,
.ansi-bg-bright-magenta {
background-color: #4b2b59;
}
.ansi-bg-cyan,
.ansi-bg-bright-cyan {
background-color: #20484f;
}
.ansi-bg-white,
.ansi-bg-bright-white {
background-color: #d7dbdf;
color: #101112;
}
@media (max-width: 760px) {
.toolbar {
align-items: stretch;