Преглед изворни кода

feat: client-side ANSI rendering in the raw terminal view — SGR bold/italic/8-16-256 colour fg+bg as styled spans, partial escape sequences buffered across chunks, other CSI codes stripped, plain-text fallback

clanker пре 1 месец
родитељ
комит
b0df1634cf
2 измењених фајлова са 163 додато и 2 уклоњено
  1. 136 2
      src/pages/task_terminal.html
  2. 27 0
      test/web_stack_test.py

+ 136 - 2
src/pages/task_terminal.html

@@ -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;
                         }

+ 27 - 0
test/web_stack_test.py

@@ -1075,6 +1075,33 @@ def main():
         check(status == 200 and "term-rejected" in page,
               "terminal page renders rejected entries in their own style")
 
+        print("== B8: ANSI SGR in the terminal view ==")
+        # Byte-exact SGR delivery: the daemon's coloured chunks flow through
+        # the store + SSE untouched (the client renderer styles them).
+        coloured = "\x1b[1;32mbold green\x1b[0m then \x1b[38;5;196m256-red\x1b[0m\n"
+        conn = db()
+        conn.execute("insert into session_raw_log (session_id, seq, chunk)"
+                     " values (?, 900, ?)", (session_id, coloured))
+        conn.commit()
+        conn.close()
+        request = urllib.request.Request(WEB + "/terminal-stream/%d" % session_id)
+        with urllib.request.urlopen(request, timeout=15) as stream:
+            coloured_event = None
+            deadline = time.monotonic() + 8
+            while time.monotonic() < deadline and coloured_event is None:
+                line = stream.readline().decode().strip()
+                if not line.startswith("data: ") or line == "data: ping":
+                    continue
+                event = json.loads(line[6:])
+                if event.get("type") == "raw" and "\x1b[1;32m" in (event.get("text") or ""):
+                    coloured_event = event
+            check(coloured_event is not None
+                  and "\x1b[38;5;196m" in coloured_event["text"],
+                  "SGR sequences stream byte-exact to the client renderer")
+        status, page = http("GET", "/tasks/%d/terminal" % stuck, timeout=15)
+        check(status == 200 and "makeAnsiRenderer" in page and "colour256" in page,
+              "terminal page embeds the ANSI renderer (SGR + 256-colour palette)")
+
         # Dispatch path: a rejected entry from /output flows into the audit
         # trail without failing (or finishing) the task.
         fake.queue_output("rejected", "echo 'too long' | saddle",