diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js
index e811a80..53baf34 100644
--- a/muxplex/frontend/app.js
+++ b/muxplex/frontend/app.js
@@ -264,6 +264,97 @@ function escapeHtml(str) {
.replace(/>/g, '>');
}
+// ---------------------------------------------------------------------------
+// ANSI escape → HTML span converter (SGR codes only)
+// Converts terminal color sequences to 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 += ''; spans--; }
+ } else if (style) {
+ out += '';
+ 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 += ''; 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) {
`${escapeHtml(name)}` +
`${bellHtml}${escapeHtml(timeStr)}` +
`` +
- `
` +
+ `` +
``
);
}
@@ -339,7 +430,7 @@ function buildSidebarHTML(session, currentSession) {
`` +
`${bellHtml}` +
`` +
- `` +
+ `` +
``
);
}
@@ -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,
diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs
index 975cbb2..a473af0 100644
--- a/muxplex/frontend/tests/test_app.mjs
+++ b/muxplex/frontend/tests/test_app.mjs
@@ -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');
+});
+
diff --git a/muxplex/sessions.py b/muxplex/sessions.py
index 3df4f2d..4b3f4d4 100644
--- a/muxplex/sessions.py
+++ b/muxplex/sessions.py
@@ -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,
diff --git a/muxplex/tests/test_sessions.py b/muxplex/tests/test_sessions.py
index a52e82a..0ae8299 100644
--- a/muxplex/tests/test_sessions.py
+++ b/muxplex/tests/test_sessions.py
@@ -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 -S -.
+ """capture_pane() calls tmux with: capture-pane -e -p -t -S -.
+ 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.