Billy Barrow 1 mesiac pred
rodič
commit
05a60eb2c4
3 zmenil súbory, kde vykonal 565 pridanie a 87 odobranie
  1. 270 60
      src/pages/app.css
  2. 172 21
      src/pages/task_log.html
  3. 123 6
      test/test_task_log_js.py

+ 270 - 60
src/pages/app.css

@@ -952,93 +952,311 @@ dialog#quote-comment-dialog blockquote {
     color: var(--plan);
 }
 
-/* Plan 17: the event log page (task_log.html) — the unified ledger's
-   human view. Turn cards group a run's turn-family events; tool
-   results collapse behind head+tail summaries with byte counts. */
-.log-toolbar {
+/* ---- Event log (task_log.html): the live console ----
+   The unified ledger's human view reads as a terminal transcript:
+   one dark shell (the app's .terminal palette) under the light
+   page, turns hanging from a rail with a violet chip, every event
+   kind glyphed and tinted, tool results parsed into prose/lists
+   and collapsed behind coloured summaries. */
+
+.log-page h2 { margin: 0; }
+.log-back { margin: 0 0 var(--grid-1); }
+
+.log-header {
+    align-items: flex-end;
     display: flex;
     flex-wrap: wrap;
-    gap: var(--grid-1, 0.5rem);
+    gap: var(--grid-2);
+    justify-content: space-between;
+    margin-bottom: var(--grid-2);
+}
+.log-title .muted { margin: 0.25rem 0 0; }
+.log-actions { align-items: center; display: flex; gap: var(--grid-1); }
+
+.log-live {
+    align-items: center;
+    background: #12261e;
+    border: 1px solid #2ea04366;
+    border-radius: 999px;
+    color: #56d364;
+    display: inline-flex;
+    font-size: 0.8rem;
+    gap: 0.4rem;
+    padding: 0.2rem 0.7rem;
+}
+.log-live.retry {
+    background: #2d2200;
+    border-color: #e3b34166;
+    color: #e3b341;
+}
+.live-dot {
+    animation: live-pulse 1.6s ease-in-out infinite;
+    background: currentColor;
+    border-radius: 50%;
+    height: 0.5rem;
+    width: 0.5rem;
+}
+@keyframes live-pulse {
+    0%, 100% { opacity: 1; }
+    50% { opacity: 0.3; }
+}
+
+.log-task-link {
+    border: 1px solid var(--line);
+    border-radius: 999px;
+    color: var(--accent);
+    padding: 0.2rem 0.7rem;
+    text-decoration: none;
+}
+.log-task-link:hover { background: var(--line-soft); }
+
+.log-shell {
+    background: #0d1117;
+    border: 1px solid #30363d;
+    border-radius: var(--radius);
+    box-shadow: 0 8px 32px #00000030;
+    overflow: hidden;
+}
+
+.log-toolbar {
     align-items: center;
-    margin-bottom: var(--grid-1, 0.5rem);
+    background: #161b22;
+    border-bottom: 1px solid #30363d;
+    display: flex;
+    flex-wrap: wrap;
+    gap: var(--grid-1);
+    padding: var(--grid-1) var(--grid-2);
 }
+.log-toolbar-label { color: #8b949e; font-size: 0.8rem; }
 .log-toolbar input[type="search"] {
+    background: #0d1117;
+    border: 1px solid #30363d;
+    border-radius: 999px;
+    color: #c9d1d9;
     flex: 1;
     min-width: 12rem;
+    padding: 0.3rem 0.9rem;
+}
+.log-toolbar input[type="search"]::placeholder { color: #8b949e; }
+
+.log-filter {
+    background: transparent;
+    border: 1px solid #30363d;
+    border-radius: 999px;
+    color: #8b949e;
+    font-size: 0.8rem;
+    padding: 0.2rem 0.75rem;
 }
+.log-filter:hover { border-color: #8b949e; color: #c9d1d9; }
 .log-filter.active {
-    border-color: var(--ink, currentColor);
-    font-weight: 600;
+    background: #1f6feb26;
+    border-color: #1f6feb;
+    color: #79c0ff;
 }
+
 .log-more {
+    background: transparent;
+    border: none;
+    color: #79c0ff;
     display: block;
-    margin: 0 auto var(--grid-1, 0.5rem);
+    margin: 0 auto;
 }
+.log-more:hover { background: transparent; text-decoration: underline; }
+
 .log-feed {
     display: flex;
     flex-direction: column;
-    gap: var(--grid-1, 0.5rem);
-    max-height: 72vh;
+    gap: var(--grid-1);
+    max-height: 75vh;
     overflow-y: auto;
-    border: 1px solid var(--line-soft, #eee);
-    border-radius: 6px;
-    padding: var(--grid-1, 0.5rem);
+    padding: var(--grid-2);
+    scrollbar-color: #30363d #0d1117;
+    scrollbar-width: thin;
+}
+.log-feed::-webkit-scrollbar { width: 10px; }
+.log-feed::-webkit-scrollbar-thumb {
+    background: #30363d;
+    border-radius: 5px;
+}
+.log-feed::-webkit-scrollbar-track { background: transparent; }
+
+.log-empty {
+    color: #8b949e;
+    padding: var(--grid-4) 0;
+    text-align: center;
 }
+
+.log-feed .muted, .log-feed .muted a { color: #8b949e; }
+
+/* Turns hang from a rail; their events sit under it. */
 .turn-card {
-    border: 1px solid var(--line-soft, #eee);
-    border-left: 3px solid var(--line, #ccc);
-    border-radius: 6px;
-    padding: 0.4rem 0.6rem;
+    border-left: 2px solid #30363d;
+    display: flex;
+    flex-direction: column;
+    gap: 0.4rem;
+    margin: var(--grid-1) 0;
+    padding: 0.2rem 0 0.2rem var(--grid-2);
 }
 .turn-head {
-    margin: 0 0 0.3rem;
-    font-size: 0.85rem;
+    align-items: center;
+    color: #8b949e;
+    display: flex;
+    font-size: 0.7rem;
+    gap: 0.5rem;
+    letter-spacing: 0.06em;
+    margin: 0;
+    text-transform: uppercase;
 }
+.turn-chip {
+    background: #bc8cff1f;
+    border: 1px solid #bc8cff66;
+    border-radius: 999px;
+    color: #d2b8ff;
+    padding: 0.05rem 0.6rem;
+}
+
+/* Event rows: a glyph gutter carries the kind, the chip names it. */
+.event-row {
+    border-radius: 6px;
+    padding: 0.1rem 0.4rem 0.1rem 1.8rem;
+    position: relative;
+}
+.event-row:hover { background: #161b22; }
+.event-row::before {
+    color: #8b949e;
+    content: "\00b7";
+    display: inline-block;
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.9rem;
+    left: 0.35rem;
+    position: absolute;
+    text-align: center;
+    top: 0.2rem;
+    width: 1rem;
+}
+.event-row.event-kind-tool_call::before { color: #79c0ff; content: "\25b8"; }
+.event-row.event-kind-tool_result::before { content: "\21b3"; }
+.event-row.event-kind-turn_text::before { color: #bc8cff; content: "\2726"; }
+.event-row.event-kind-command_output::before { color: #79c0ff; content: "\203a"; }
+.event-row.event-kind-progress::before { color: #56d364; content: "\25c6"; }
+.event-row.event-kind-comment::before { color: #e3b341; content: "\201c"; }
+.event-row.event-kind-answer::before { color: #56d364; content: "\2713"; }
+.event-row.event-kind-verdict::before { color: #e3b341; content: "\2261"; }
+.event-row.event-kind-usage::before { content: "\2211"; }
+.event-row.event-kind-state_change::before { color: #79c0ff; content: "\2192"; }
+.event-row.event-kind-lifecycle::before { color: #bc8cff; content: "\25cf"; }
+.event-row.event-kind-force_wake::before { color: #e3b341; content: "\21d1"; }
+.event-row.event-kind-cancel::before { color: #ff7b72; content: "\00d7"; }
+.event-row.event-kind-yeehaw::before { color: #f8e8b8; content: "\2605"; }
+
 .event-row .event-head {
-    margin: 0.2rem 0;
+    align-items: baseline;
+    display: flex;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+    margin: 0.15rem 0;
 }
+
+.log-feed .badge {
+    border-radius: 999px;
+    font-size: 0.7rem;
+    letter-spacing: 0.03em;
+    padding: 0.05rem 0.55rem;
+}
+.event-kind-turn_text { background: #bc8cff1f; color: #d2b8ff; }
+.event-kind-tool_call { background: #1f6feb26; color: #79c0ff; }
+.event-kind-tool_result { background: #30363d55; color: #8b949e; }
+.event-kind-command_output { background: #1f6feb26; color: #79c0ff; }
+.event-kind-progress { background: #2ea04326; color: #56d364; }
+.event-kind-comment { background: #e3b3411f; color: #e3b341; }
+.event-kind-answer { background: #2ea04326; color: #56d364; }
+.event-kind-verdict { background: #e3b3411f; color: #e3b341; }
+.event-kind-state_change { background: #30363d55; color: #79c0ff; }
+.event-kind-lifecycle { background: #bc8cff1f; color: #d2b8ff; }
+.event-kind-usage { background: #30363d55; color: #8b949e; }
+.event-kind-force_wake { background: #e3b3411f; color: #e3b341; }
+.event-kind-cancel { background: #f8514926; color: #ff7b72; }
+.event-kind-yeehaw { background: #f8e8b826; color: #f8e8b8; }
+
+.log-feed p { margin: 0.15rem 0; }
+
 .event-pre {
-    margin: 0.2rem 0;
-    padding: 0.3rem 0.5rem;
-    background: var(--line-soft, #f6f8fa);
+    background: #161b22;
+    border: 1px solid #21262d;
     border-radius: 4px;
+    color: #c9d1d9;
+    font-size: 0.8rem;
+    margin: 0.2rem 0;
     overflow-x: auto;
-    font-size: 0.85rem;
+    padding: 0.3rem 0.5rem;
     white-space: pre-wrap;
     word-break: break-word;
 }
-.event-pre.event-stderr {
-    color: var(--danger, #cf222e);
-}
 .event-pre.event-command {
-    background: #ddf4ff;
-    font-weight: 600;
+    background: #79c0ff0d;
+    border-color: #79c0ff33;
+    color: #79c0ff;
 }
-.event-result-body {
-    margin-top: 0.3rem;
+.event-pre.event-stderr {
+    background: #ff7b720d;
+    border-color: #ff7b7233;
+    color: #ff7b72;
 }
-/* Plan 18: the event log's tailored per-tool bodies - the turn's
-   commentary block, the folded long payloads, the usage one-liner,
-   the side-LLM question and the replace_in_file diff. */
+
 .event-turn {
-    border-left: 3px solid #ddf4ff;
-    color: var(--ink-muted);
+    background: #bc8cff0d;
+    border-color: #bc8cff4d;
+    border-left: 3px solid #bc8cff;
+    color: #d2b8ff;
 }
+
+.event-question { color: #e3b341; font-style: italic; }
+.event-usage {
+    color: #8b949e;
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.8rem;
+}
+
 .event-fold { margin: 0.2rem 0; }
 .event-fold > summary {
-    color: var(--ink-muted);
+    color: #8b949e;
     cursor: pointer;
-    font-size: 0.85rem;
+    font-size: 0.8rem;
 }
+.event-fold > summary:hover { color: #c9d1d9; }
 .event-fold[open] > summary { margin-bottom: 0.2rem; }
-.event-question { font-style: italic; }
-.event-usage {
-    color: var(--ink-muted);
-    font-size: 0.85rem;
+
+/* Tool results: coloured summary line, parsed bodies. */
+.event-result > summary {
+    color: #56d364;
+    cursor: pointer;
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.8rem;
+}
+.event-result > summary.failed, .event-result > summary.failed ~ * .result-ok { color: #ff7b72; }
+.event-result > summary::marker { color: #8b949e; }
+.event-result-body { margin-top: 0.3rem; }
+.result-ok { color: #56d364; font-family: ui-monospace, "Cascadia Mono", monospace; font-size: 0.85rem; }
+.result-meta {
+    color: #8b949e;
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.8rem;
 }
+.result-tasks { margin: 0.2rem 0; padding-left: 1.2rem; }
+.result-tasks li { margin: 0.15rem 0; }
+.result-tasks .badge { margin-left: 0.5rem; }
+
+.log-feed blockquote {
+    border-left: 3px solid #30363d;
+    color: #c9d1d9;
+    margin: 0.2rem 0;
+    padding: 0 0.75rem;
+}
+.log-feed .yeehaw-message { color: #f8e8b8; }
+
 .event-diff {
-    background: var(--surface-sunken);
-    border: 1px solid var(--line-soft);
+    background: #0d1117;
+    border: 1px solid #21262d;
     border-radius: 6px;
     font-family: ui-monospace, "Cascadia Mono", monospace;
     font-size: 0.8rem;
@@ -1052,18 +1270,10 @@ dialog#quote-comment-dialog blockquote {
     white-space: pre-wrap;
     word-break: break-word;
 }
-.diff-del { background: #ffebe9; color: var(--danger); }
-.diff-add { background: #dafbe1; color: var(--success); }
-.diff-ctx { color: var(--ink-muted); }
-.event-kind-turn_text { background: var(--line-soft, #f0f0f0); color: #6e7781; }
-.event-kind-tool_call { background: #ddf4ff; color: #0969da; }
-.event-kind-tool_result { background: var(--line-soft, #f0f0f0); color: #6e7781; }
-.event-kind-progress { background: #dafbe1; color: var(--success, #1a7f37); }
-.event-kind-comment { background: #f0f0f0; color: #6e7781; }
-.event-kind-answer { background: #ddf4ff; color: #0969da; }
-.event-kind-verdict { background: #fff1e0; color: #9a4700; }
-.event-kind-state_change { background: #fff8c5; color: var(--warning, #9a6700); }
-.event-kind-lifecycle { background: #f3e8ff; color: var(--plan, #8250df); }
-.event-kind-usage { background: var(--line-soft, #f0f0f0); color: #6e7781; }
-.event-kind-force_wake { background: #fff8c5; color: var(--warning, #9a6700); }
-.event-kind-cancel { background: #ffebe9; color: var(--danger, #cf222e); }
+.diff-del { background: #3d1418; color: #ffa7a4; }
+.diff-add { background: #12261e; color: #7ee2a8; }
+.diff-ctx { color: #8b949e; }
+
+@media (max-width: 40rem) {
+    .log-header { align-items: flex-start; flex-direction: column; }
+}

+ 172 - 21
src/pages/task_log.html

@@ -95,6 +95,19 @@
             var feed = null;
             var moreButton = null;
             var stream = null;
+            var liveBadge = null;
+            var liveLabel = null;
+
+            // The live pill: green pulse while the stream holds,
+            // amber while EventSource retries after a drop.
+            function setLive(state) {
+                if (!liveBadge) return;
+                liveBadge.className = 'log-live' + (state === 'retry' ? ' retry' : '');
+                if (liveLabel) {
+                    liveLabel.textContent = state === 'retry'
+                        ? 'reconnecting...' : 'live';
+                }
+            }
 
             function kindClass(kind) {
                 return 'event-kind-' + String(kind || '').replace(/[^a-z0-9-]/g, '');
@@ -243,6 +256,9 @@
             function resultSummary(evt, envelope) {
                 var p = evt.payload || {};
                 var wrap = document.createElement('summary');
+                var failed = (typeof envelope.exit_code === 'number'
+                        && envelope.exit_code !== 0) || envelope.ok === false;
+                wrap.className = 'result-summary' + (failed ? ' failed' : '');
                 wrap.appendChild(document.createTextNode('result'));
                 var bits = [];
                 if (envelope.exit_code !== undefined
@@ -315,11 +331,11 @@
                     // Empty stdout AND stderr. An error/note/
                     // timeout member still said something, and a
                     // non-run_command tool's answer IS the
-                    // envelope ({"recorded":true}, {"ok":true,
-                    // "task_id":4}, a tasks list) - render it
-                    // instead of claiming silence. Only an
-                    // envelope with nothing left to say is
-                    // "(no output)".
+                    // envelope - render it through the tailored
+                    // result body first (a read window, a
+                    // replacement count, a task list), then the
+                    // raw JSON. Only an envelope with nothing left
+                    // to say is "(no output)".
                     var note = strText(envelope.error) || strText(envelope.note)
                         || (envelope.timeout ? 'timed out' : '');
                     if (note.length > 0) {
@@ -328,7 +344,10 @@
                         div.appendChild(notePre);
                     } else {
                         var extras = resultExtras(envelope);
-                        if (Object.keys(extras).length > 0) {
+                        var bespoke = tailoredResultBody(strText(p.tool), extras);
+                        if (bespoke) {
+                            div.appendChild(bespoke);
+                        } else if (Object.keys(extras).length > 0) {
                             var compact = JSON.stringify(extras);
                             div.appendChild(preLine(compact.length <= 120
                                 ? compact : JSON.stringify(extras, null, 2)));
@@ -343,6 +362,114 @@
                 return div;
             }
 
+            // ---- tailored result bodies ----
+            //
+            // The daemon's file and task tools answer with
+            // structured extras (a line window of a file, a
+            // replacement count, a task list) that deserve better
+            // than a JSON dump. Answers null for the generic view.
+            function okPara(text) {
+                var node = para(text);
+                node.className = 'result-ok';
+                return node;
+            }
+
+            function taskList(items) {
+                var list = document.createElement('ul');
+                list.className = 'result-tasks';
+                items.forEach(function (item) {
+                    if (!isPlainObject(item)) return;
+                    var li = document.createElement('li');
+                    li.textContent = '#' + (item.id !== undefined ? item.id : '?')
+                        + '  ' + strText(item.title);
+                    var status = strText(item.status);
+                    if (status.length > 0) {
+                        var chip = document.createElement('span');
+                        chip.className = 'badge event-kind-state_change';
+                        chip.textContent = status;
+                        li.appendChild(chip);
+                    }
+                    list.appendChild(li);
+                });
+                return list;
+            }
+
+            function tailoredResultBody(tool, extras) {
+                var frag = document.createDocumentFragment();
+                if (strText(extras.content).length > 0
+                        || typeof extras.total_lines === 'number') {
+                    // read_file's answer: a line window of a file.
+                    var meta = [];
+                    if (strText(extras.path).length > 0) {
+                        meta.push(strText(extras.path));
+                    }
+                    if (typeof extras.line_start === 'number'
+                            && typeof extras.total_lines === 'number') {
+                        meta.push('lines ' + extras.line_start + '-'
+                            + (typeof extras.line_end === 'number'
+                                ? extras.line_end : extras.total_lines)
+                            + ' of ' + extras.total_lines);
+                    }
+                    if (typeof extras.bytes === 'number') {
+                        meta.push(fmtBytes(extras.bytes));
+                    }
+                    if (extras.truncated) meta.push('truncated');
+                    if (meta.length > 0) {
+                        frag.appendChild(para(meta.join(' \u00b7 '), 'result-meta'));
+                    }
+                    if (strText(extras.content).length > 0) {
+                        frag.appendChild(preLine(extras.content));
+                    }
+                    return frag;
+                }
+                if (tool === 'replace_in_file' && typeof extras.replacements === 'number') {
+                    return okPara(extras.replacements + ' replacement'
+                        + (extras.replacements === 1 ? '' : 's') + ' in '
+                        + strText(extras.path) + ' \u00b7 '
+                        + fmtBytes(typeof extras.bytes_written === 'number'
+                            ? extras.bytes_written : 0));
+                }
+                if (tool === 'write_file' && typeof extras.bytes === 'number') {
+                    return okPara('wrote ' + strText(extras.path) + ' \u00b7 '
+                        + fmtBytes(extras.bytes));
+                }
+                if (tool === 'overwrite_file' && typeof extras.bytes_written === 'number') {
+                    return okPara('rewrote ' + strText(extras.path) + ' \u00b7 '
+                        + fmtBytes(extras.bytes_written)
+                        + (typeof extras.previous_bytes === 'number'
+                            ? ' (was ' + fmtBytes(extras.previous_bytes) + ')' : ''));
+                }
+                if (tool === 'create_task' && typeof extras.task_id === 'number') {
+                    return okPara('created task #' + extras.task_id
+                        + (strText(extras.status).length > 0
+                            ? ' \u00b7 ' + strText(extras.status) : ''));
+                }
+                if (tool === 'get_task' && extras.id !== undefined) {
+                    return taskList([extras]);
+                }
+                if (Array.isArray(extras.tasks)) return taskList(extras.tasks);
+                if (Array.isArray(extras.goals)) return taskList(extras.goals);
+                var keys = Object.keys(extras);
+                var allTrue = keys.length > 0;
+                var allScalars = keys.length > 0 && keys.length <= 4;
+                for (var i = 0; i < keys.length; i++) {
+                    var value = extras[keys[i]];
+                    if (value !== true) allTrue = false;
+                    if (value !== null && typeof value === 'object') allScalars = false;
+                }
+                // A pure acknowledgement ({recorded:true},
+                // {accepted:true}): a green ok line, not JSON.
+                if (allTrue) return okPara('ok');
+                if (allScalars) {
+                    var bits = [];
+                    for (var j = 0; j < keys.length; j++) {
+                        bits.push(keys[j] + ': ' + extras[keys[j]]);
+                    }
+                    return para(bits.join(' \u00b7 '), 'result-meta');
+                }
+                return null;
+            }
+
             // The tool call renders a TAILORED body per tool: the
             // head line always carries the tool and its target (a
             // $ command, a file path, a task id) and the payload
@@ -699,7 +826,7 @@
 
             function eventNode(evt) {
                 var row = document.createElement('div');
-                row.className = 'event-row';
+                row.className = 'event-row ' + kindClass(evt.kind);
                 var head = document.createElement('p');
                 head.className = 'event-head';
                 var badge = document.createElement('span');
@@ -829,14 +956,24 @@
                 var card = document.createElement('div');
                 card.className = 'turn-card';
                 var head = document.createElement('p');
-                head.className = 'turn-head muted';
-                var bits = [];
+                head.className = 'turn-head';
                 if (events[0].payload && events[0].payload.turn !== undefined
                         && events[0].payload.turn !== null) {
-                    bits.push('turn ' + events[0].payload.turn);
+                    var chip = document.createElement('span');
+                    chip.className = 'turn-chip';
+                    chip.textContent = 'turn ' + events[0].payload.turn;
+                    head.appendChild(chip);
+                } else {
+                    var plain = document.createElement('span');
+                    plain.textContent = 'agent turn';
+                    head.appendChild(plain);
                 }
-                if (events[0].session_id) bits.push('session ' + events[0].session_id);
-                head.textContent = bits.length > 0 ? bits.join(' \u00b7 ') : 'agent turn';
+                var meta = document.createElement('span');
+                meta.textContent = events.length + ' event'
+                    + (events.length === 1 ? '' : 's')
+                    + (events[0].session_id
+                        ? ' \u00b7 session ' + events[0].session_id : '');
+                head.appendChild(meta);
                 card.appendChild(head);
                 events.forEach(function (evt) {
                     card.appendChild(eventNode(evt));
@@ -864,7 +1001,7 @@
                 var visibleRows = state.events.filter(visible);
                 if (visibleRows.length === 0) {
                     var empty = document.createElement('p');
-                    empty.className = 'muted';
+                    empty.className = 'muted log-empty';
                     empty.textContent = state.events.length === 0
                         ? 'Waiting for the first events\u2026'
                         : 'No events match the filters.';
@@ -950,7 +1087,11 @@
                 var base = '/tasks/' + log.task_id;
                 stream = new EventSource(base + '/events/stream');
                 stream.onmessage = onStreamMessage;
+                stream.onopen = function () {
+                    setLive('live');
+                };
                 stream.onerror = function () {
+                    setLive('retry');
                     // EventSource retries on its own (retry: 3s
                     // hint); the reconnect replays from the
                     // last seen id and ingest dedupes it.
@@ -1015,6 +1156,8 @@
                 if (!page || !page.exists) return;
                 feed = document.querySelector('[data-log-feed]');
                 moreButton = document.querySelector('[data-log-more]');
+                liveBadge = document.querySelector('[data-log-live]');
+                liveLabel = document.querySelector('[data-live-label]');
                 var header = document.querySelector('[data-log-title]');
                 if (header) {
                     header.textContent = '#' + page.task_id + ' \u2014 '
@@ -1029,16 +1172,24 @@
 </head>
 <body>
     <div class="page log-page">
-        <p><a href="/tasks">&larr; Tasks</a></p>
-        <h2 data-log-title>Event log</h2>
+        <p class="log-back"><a href="/tasks">&larr; Tasks</a></p>
         <div stm-if="!task_log.exists" class="muted">No task exists with this id.</div>
         <div stm-if="task_log.exists">
-            <p><a stm-attribute.href="task_log.task_url">Task page</a>
-                <span class="muted">· the unified event ledger, live (most recent 30;
-                    loads older on demand)</span></p>
-            <section class="card">
+            <header class="log-header">
+                <div class="log-title">
+                    <h2 data-log-title>Event log</h2>
+                    <p class="muted">the unified event ledger · newest at the bottom ·
+                        loads older history on demand</p>
+                </div>
+                <div class="log-actions">
+                    <span class="log-live" data-log-live><span class="live-dot"></span><span
+                            data-live-label>live</span></span>
+                    <a class="log-task-link" stm-attribute.href="task_log.task_url">Task page</a>
+                </div>
+            </header>
+            <section class="log-shell">
                 <div class="log-toolbar">
-                    <span class="muted">Show:</span>
+                    <span class="log-toolbar-label">Show</span>
                     <button type="button" class="log-filter active" data-log-filter="commands">commands</button>
                     <button type="button" class="log-filter active" data-log-filter="results">results</button>
                     <button type="button" class="log-filter active" data-log-filter="comments">comments</button>
@@ -1048,7 +1199,7 @@
                 </div>
                 <button type="button" class="log-more" data-log-more hidden>Load older events</button>
                 <div class="log-feed" data-log-feed>
-                    <p class="muted">Waiting for the first events…</p>
+                    <p class="muted log-empty">Waiting for the first events…</p>
                 </div>
             </section>
         </div>

+ 123 - 6
test/test_task_log_js.py

@@ -545,7 +545,9 @@ def result_summary(evt):
 
 def result_output(evt):
     """PORT of resultBody's text decisions -> (stdout_text,
-    stderr_text, note_text, extras_text, no_output_flag)."""
+    stderr_text, note_text, extras_text, no_output_flag). The extras
+    go through the tailored result body first (a read window, a
+    replacement count, a task list, an ok ack), then raw JSON."""
     envelope = result_envelope(evt)
     payload = evt.get("payload") or {}
     parsed = parse_maybe_json(payload.get("result"))
@@ -568,12 +570,90 @@ def result_output(evt):
         if not note:
             remainder = result_extras(envelope)
             if remainder:
-                compact = json.dumps(remainder)
-                extras = compact if len(compact) <= 120 \
-                    else json.dumps(remainder, indent=2)
+                tailored = tailored_result_text(
+                    str_text(payload.get("tool")), remainder)
+                if tailored is not None:
+                    extras = tailored
+                else:
+                    compact = json.dumps(remainder)
+                    extras = compact if len(compact) <= 120 \
+                        else json.dumps(remainder, indent=2)
     return out, err, note, extras, (not out and not err and not note and not extras)
 
 
+def task_list_text(items):
+    """PORT of taskList -> one '#id title status' line per item."""
+    lines = []
+    for item in items:
+        if not is_plain_object(item):
+            continue
+        line = "#%s  %s" % (item.get("id", "?"), str_text(item.get("title")))
+        if str_text(item.get("status")):
+            line += " " + str_text(item.get("status"))
+        lines.append(line)
+    return "\n".join(lines)
+
+
+def tailored_result_text(tool, extras):
+    """PORT of tailoredResultBody -> the rendered text, or None for
+    the generic JSON view."""
+    if str_text(extras.get("content")) or isinstance(extras.get("total_lines"), int):
+        meta = []
+        if str_text(extras.get("path")):
+            meta.append(str_text(extras.get("path")))
+        if isinstance(extras.get("line_start"), int) \
+                and isinstance(extras.get("total_lines"), int):
+            end = extras.get("line_end")
+            if not isinstance(end, int):
+                end = extras["total_lines"]
+            meta.append("lines %d-%d of %d"
+                        % (extras["line_start"], end, extras["total_lines"]))
+        if isinstance(extras.get("bytes"), int):
+            meta.append(fmt_bytes(extras["bytes"]))
+        if extras.get("truncated"):
+            meta.append("truncated")
+        parts = [" \u00b7 ".join(meta)]
+        if str_text(extras.get("content")):
+            parts.append(str_text(extras["content"]))
+        return "\n".join(parts)
+    if tool == "replace_in_file" and isinstance(extras.get("replacements"), int):
+        count = extras["replacements"]
+        written = extras.get("bytes_written") \
+            if isinstance(extras.get("bytes_written"), int) else 0
+        return "%d replacement%s in %s \u00b7 %s" % (
+            count, "" if count == 1 else "s",
+            str_text(extras.get("path")), fmt_bytes(written))
+    if tool == "write_file" and isinstance(extras.get("bytes"), int):
+        return "wrote %s \u00b7 %s" % (str_text(extras.get("path")),
+                                       fmt_bytes(extras["bytes"]))
+    if tool == "overwrite_file" and isinstance(extras.get("bytes_written"), int):
+        text = "rewrote %s \u00b7 %s" % (str_text(extras.get("path")),
+                                         fmt_bytes(extras["bytes_written"]))
+        if isinstance(extras.get("previous_bytes"), int):
+            text += " (was %s)" % fmt_bytes(extras["previous_bytes"])
+        return text
+    if tool == "create_task" and isinstance(extras.get("task_id"), int):
+        text = "created task #%d" % extras["task_id"]
+        if str_text(extras.get("status")):
+            text += " \u00b7 " + str_text(extras.get("status"))
+        return text
+    if tool == "get_task" and "id" in extras:
+        return task_list_text([extras])
+    for key in ("tasks", "goals"):
+        if isinstance(extras.get(key), list):
+            return task_list_text(extras[key])
+    keys = list(extras)
+    all_true = len(keys) > 0 and all(extras[key] is True for key in keys)
+    all_scalars = 0 < len(keys) <= 4 and all(
+        extras[key] is None or not isinstance(extras[key], (dict, list))
+        for key in keys)
+    if all_true:
+        return "ok"
+    if all_scalars:
+        return " \u00b7 ".join("%s: %s" % (key, extras[key]) for key in keys)
+    return None
+
+
 def args_without(args, skip):
     """PORT of argsWithout: every member except `skip`, pretty-
     printed; '' when nothing is left."""
@@ -1086,15 +1166,52 @@ class ToolResultRenderTests(unittest.TestCase):
             self.assertEqual((out, err, note), ("", "", ""))
             self.assertTrue(extras, inner)
 
-    def test_list_tasks_answer_renders_as_json(self):
+    def test_list_tasks_answer_renders_as_a_list(self):
         inner = json.dumps({"ok": True, "tasks": [
             {"id": 6, "title": "UX notes", "status": "queued"},
             {"id": 5, "title": "Event log parse", "status": "working"}]})
         out, err, note, extras, no_output = result_output(self.evt(
             {"tool": "list_tasks", "result": inner}))
         self.assertFalse(no_output)
-        self.assertIn('"tasks"', extras)
         self.assertIn("UX notes", extras)
+        self.assertIn("Event log parse", extras)
+        self.assertIn("#6", extras)
+        self.assertNotIn('"tasks"', extras,
+                         "a task list renders as rows, not a JSON dump")
+
+    def test_replace_in_file_result_counts_the_replacements(self):
+        inner = json.dumps({"ok": True, "path": "src/app.css",
+                            "replacements": 2, "bytes_written": 2048})
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "replace_in_file", "result": inner}))
+        self.assertEqual(extras, "2 replacements in src/app.css \u00b7 2.0 KiB")
+
+    def test_read_file_result_renders_the_window(self):
+        inner = json.dumps({"ok": True, "path": "src/main.vala",
+                            "total_lines": 540, "line_start": 100,
+                            "line_end": 160, "bytes": 4200,
+                            "truncated": False, "content": "int main() {"})
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "read_file", "result": inner}))
+        self.assertIn("src/main.vala \u00b7 lines 100-160 of 540 \u00b7 4.1 KiB", extras)
+        self.assertIn("int main() {", extras)
+
+    def test_write_file_result_states_the_write(self):
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "write_file",
+             "result": '{"ok":true,"path":"notes.md","bytes":24}'}))
+        self.assertEqual(extras, "wrote notes.md \u00b7 24 B")
+
+    def test_create_task_result_names_the_task(self):
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "create_task",
+             "result": '{"ok":true,"task_id":4,"status":"draft"}'}))
+        self.assertEqual(extras, "created task #4 \u00b7 draft")
+
+    def test_pure_acknowledgement_is_an_ok_line(self):
+        out, err, note, extras, no_output = result_output(self.evt(
+            {"tool": "update_progress", "result": '{"ok":true,"recorded":true}'}))
+        self.assertEqual(extras, "ok")
 
     def test_run_command_with_empty_streams_is_still_no_output(self):
         """Only command plumbing present: the run genuinely said