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)}` + `` + - `
${escapeHtml(lastLines)}
` + + `
${ansiToHtml(lastLines)}
` + `` ); } @@ -339,7 +430,7 @@ function buildSidebarHTML(session, currentSession) { `${escapedName}` + `${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.