feat: ANSI color rendering in preview snapshots
Server: add -e flag to tmux capture-pane to preserve escape sequences. Frontend: ansiToHtml() parser converts SGR codes (bold, italic, dim, 16-color, 256-color) to <span> tags with inline styles. Applied to dashboard tiles, sidebar items, and hover overlay. No external deps.
This commit is contained in:
+99
-4
@@ -264,6 +264,97 @@ function escapeHtml(str) {
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ANSI escape → HTML span converter (SGR codes only)
|
||||
// Converts terminal color sequences to <span> tags with inline styles.
|
||||
// ---------------------------------------------------------------------------
|
||||
var ANSI_COLORS = [
|
||||
'#000','#c23621','#25bc26','#adad27','#492ee1','#d338d3','#33bbc8','#cbcccd',
|
||||
'#818383','#fc391f','#31e722','#eaec23','#5833ff','#f935f8','#14f0f0','#e9ebeb'
|
||||
];
|
||||
|
||||
function ansiToHtml(raw) {
|
||||
if (!raw) return '';
|
||||
var out = '';
|
||||
var spans = 0;
|
||||
var i = 0;
|
||||
var len = raw.length;
|
||||
|
||||
while (i < len) {
|
||||
// Look for ESC [ ... m (SGR sequence)
|
||||
if (raw[i] === '\x1b' && raw[i + 1] === '[') {
|
||||
var j = i + 2;
|
||||
while (j < len && raw[j] !== 'm' && j - i < 20) j++;
|
||||
if (j < len && raw[j] === 'm') {
|
||||
var params = raw.substring(i + 2, j).split(';');
|
||||
var style = ansiParamsToStyle(params);
|
||||
if (style === 'reset') {
|
||||
// Close all open spans
|
||||
while (spans > 0) { out += '</span>'; spans--; }
|
||||
} else if (style) {
|
||||
out += '<span style="' + style + '">';
|
||||
spans++;
|
||||
}
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Escape HTML characters
|
||||
var ch = raw[i];
|
||||
if (ch === '<') out += '<';
|
||||
else if (ch === '>') out += '>';
|
||||
else if (ch === '&') out += '&';
|
||||
else if (ch === '"') out += '"';
|
||||
else out += ch;
|
||||
i++;
|
||||
}
|
||||
while (spans > 0) { out += '</span>'; spans--; }
|
||||
return out;
|
||||
}
|
||||
|
||||
function ansiParamsToStyle(params) {
|
||||
var styles = [];
|
||||
var k = 0;
|
||||
while (k < params.length) {
|
||||
var p = parseInt(params[k], 10) || 0;
|
||||
if (p === 0) return 'reset';
|
||||
if (p === 1) styles.push('font-weight:bold');
|
||||
else if (p === 2) styles.push('opacity:0.7');
|
||||
else if (p === 3) styles.push('font-style:italic');
|
||||
else if (p === 4) styles.push('text-decoration:underline');
|
||||
else if (p === 7) styles.push('filter:invert(1)');
|
||||
else if (p === 9) styles.push('text-decoration:line-through');
|
||||
else if (p >= 30 && p <= 37) styles.push('color:' + ANSI_COLORS[p - 30]);
|
||||
else if (p === 38 && params[k + 1] === '5') {
|
||||
var c = parseInt(params[k + 2], 10) || 0;
|
||||
styles.push('color:' + ansi256Color(c));
|
||||
k += 2;
|
||||
}
|
||||
else if (p === 39) styles.push('color:inherit');
|
||||
else if (p >= 40 && p <= 47) styles.push('background:' + ANSI_COLORS[p - 40]);
|
||||
else if (p === 48 && params[k + 1] === '5') {
|
||||
var c2 = parseInt(params[k + 2], 10) || 0;
|
||||
styles.push('background:' + ansi256Color(c2));
|
||||
k += 2;
|
||||
}
|
||||
else if (p === 49) styles.push('background:inherit');
|
||||
else if (p >= 90 && p <= 97) styles.push('color:' + ANSI_COLORS[p - 90 + 8]);
|
||||
else if (p >= 100 && p <= 107) styles.push('background:' + ANSI_COLORS[p - 100 + 8]);
|
||||
k++;
|
||||
}
|
||||
return styles.length ? styles.join(';') : '';
|
||||
}
|
||||
|
||||
function ansi256Color(n) {
|
||||
if (n < 16) return ANSI_COLORS[n];
|
||||
if (n >= 232) { var g = 8 + (n - 232) * 10; return 'rgb(' + g + ',' + g + ',' + g + ')'; }
|
||||
n -= 16;
|
||||
var r = Math.floor(n / 36) * 51;
|
||||
var g2 = Math.floor((n % 36) / 6) * 51;
|
||||
var b = (n % 6) * 51;
|
||||
return 'rgb(' + r + ',' + g2 + ',' + b + ')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the HTML string for a single session tile.
|
||||
* @param {object} session
|
||||
@@ -301,7 +392,7 @@ function buildTileHTML(session, index, mobile) {
|
||||
`<span class="tile-name">${escapeHtml(name)}</span>` +
|
||||
`<span class="tile-meta">${bellHtml}<span class="tile-time">${escapeHtml(timeStr)}</span></span>` +
|
||||
`</div>` +
|
||||
`<div class="tile-body"><pre>${escapeHtml(lastLines)}</pre></div>` +
|
||||
`<div class="tile-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
@@ -339,7 +430,7 @@ function buildSidebarHTML(session, currentSession) {
|
||||
`<span class="sidebar-item-name">${escapedName}</span>` +
|
||||
`${bellHtml}` +
|
||||
`</div>` +
|
||||
`<div class="sidebar-item-body"><pre>${escapeHtml(lastLines)}</pre></div>` +
|
||||
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||
`</article>`
|
||||
);
|
||||
}
|
||||
@@ -540,7 +631,7 @@ function showPreview(name) {
|
||||
// If already showing this session, just update content
|
||||
if (_previewPopover && _previewSessionName === name) {
|
||||
var pre = _previewPopover.querySelector('pre');
|
||||
if (pre) pre.textContent = session.snapshot;
|
||||
if (pre) pre.innerHTML = ansiToHtml(session.snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -557,7 +648,7 @@ function showPreview(name) {
|
||||
var popover = document.createElement('div');
|
||||
popover.className = 'preview-popover';
|
||||
var pre = document.createElement('pre');
|
||||
pre.textContent = session.snapshot;
|
||||
pre.innerHTML = ansiToHtml(session.snapshot);
|
||||
popover.appendChild(pre);
|
||||
document.body.appendChild(popover);
|
||||
_previewPopover = popover;
|
||||
@@ -1259,6 +1350,10 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
closeBottomSheet,
|
||||
renderSheetList,
|
||||
updateSessionPill,
|
||||
// ANSI color rendering
|
||||
ansiToHtml,
|
||||
ansiParamsToStyle,
|
||||
ansi256Color,
|
||||
// Hover preview popover
|
||||
showPreview,
|
||||
hidePreview,
|
||||
|
||||
@@ -2119,4 +2119,15 @@ test('renderGrid and renderSidebar re-lift hovered tile after innerHTML rebuild'
|
||||
'renderGrid must NOT skip renders while preview active (old approach caused bugs)');
|
||||
});
|
||||
|
||||
test('ansiToHtml converts SGR codes to styled spans', () => {
|
||||
const source = fs.readFileSync(
|
||||
new URL('../app.js', import.meta.url), 'utf8'
|
||||
);
|
||||
assert.ok(source.includes('function ansiToHtml'), 'ansiToHtml parser must exist');
|
||||
assert.ok(source.includes('ANSI_COLORS'), 'must have color lookup table');
|
||||
assert.ok(source.includes('ansi256Color'), 'must support 256-color mode');
|
||||
assert.ok(source.includes('ansiToHtml(lastLines)'), 'tiles must use ansiToHtml not escapeHtml');
|
||||
assert.ok(source.includes('ansiToHtml(session.snapshot)'), 'overlay must use ansiToHtml');
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ async def capture_pane(session_name: str, lines: int = 30) -> str:
|
||||
try:
|
||||
return await run_tmux(
|
||||
"capture-pane",
|
||||
"-e", # preserve ANSI escape sequences for color rendering
|
||||
"-p",
|
||||
"-t",
|
||||
session_name,
|
||||
|
||||
@@ -139,10 +139,11 @@ async def test_capture_pane_returns_empty_string_on_error(mock_subprocess):
|
||||
|
||||
|
||||
async def test_capture_pane_calls_correct_tmux_args(mock_subprocess):
|
||||
"""capture_pane() calls tmux with: capture-pane -p -t <name> -S -<lines>.
|
||||
"""capture_pane() calls tmux with: capture-pane -e -p -t <name> -S -<lines>.
|
||||
|
||||
Uses -e to preserve ANSI escape sequences for color rendering.
|
||||
Uses -S -N (start N lines from bottom) to limit output.
|
||||
Does NOT pass -e (escape sequences) or -l (invalid in tmux 3.4).
|
||||
Does NOT pass -l (invalid in tmux 3.4).
|
||||
"""
|
||||
with mock_subprocess("output text\n") as mock_create:
|
||||
await capture_pane("target-session", lines=50)
|
||||
@@ -150,12 +151,13 @@ async def test_capture_pane_calls_correct_tmux_args(mock_subprocess):
|
||||
call_args = mock_create.call_args[0]
|
||||
assert call_args[0] == "tmux"
|
||||
assert call_args[1] == "capture-pane"
|
||||
assert call_args[2] == "-p"
|
||||
assert call_args[3] == "-t"
|
||||
assert call_args[4] == "target-session"
|
||||
assert call_args[5] == "-S"
|
||||
assert call_args[6] == "-50"
|
||||
assert len(call_args) == 7, "No extra args — -e and -l must not be present"
|
||||
assert call_args[2] == "-e"
|
||||
assert call_args[3] == "-p"
|
||||
assert call_args[4] == "-t"
|
||||
assert call_args[5] == "target-session"
|
||||
assert call_args[6] == "-S"
|
||||
assert call_args[7] == "-50"
|
||||
assert len(call_args) == 8, "-e must be present; no other extra args"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -211,6 +213,15 @@ async def test_snapshot_all_returns_empty_string_on_individual_failure():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_capture_pane_uses_escape_flag():
|
||||
"""capture-pane must include -e for ANSI color preservation."""
|
||||
import inspect
|
||||
from muxplex.sessions import capture_pane
|
||||
|
||||
source = inspect.getsource(capture_pane)
|
||||
assert '"-e"' in source, "capture_pane must pass -e flag to preserve ANSI escapes"
|
||||
|
||||
|
||||
def test_update_session_cache_populates_snapshots():
|
||||
"""update_session_cache(names, snapshots) must replace _snapshots with provided dict.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user