Ver Fonte

New task view 2

Billy Barrow há 1 mês atrás
pai
commit
05a60eb2c4
3 ficheiros alterados com 565 adições e 87 exclusões
  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);
     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;
     display: flex;
     flex-wrap: wrap;
     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;
     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"] {
 .log-toolbar input[type="search"] {
+    background: #0d1117;
+    border: 1px solid #30363d;
+    border-radius: 999px;
+    color: #c9d1d9;
     flex: 1;
     flex: 1;
     min-width: 12rem;
     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 {
 .log-filter.active {
-    border-color: var(--ink, currentColor);
-    font-weight: 600;
+    background: #1f6feb26;
+    border-color: #1f6feb;
+    color: #79c0ff;
 }
 }
+
 .log-more {
 .log-more {
+    background: transparent;
+    border: none;
+    color: #79c0ff;
     display: block;
     display: block;
-    margin: 0 auto var(--grid-1, 0.5rem);
+    margin: 0 auto;
 }
 }
+.log-more:hover { background: transparent; text-decoration: underline; }
+
 .log-feed {
 .log-feed {
     display: flex;
     display: flex;
     flex-direction: column;
     flex-direction: column;
-    gap: var(--grid-1, 0.5rem);
-    max-height: 72vh;
+    gap: var(--grid-1);
+    max-height: 75vh;
     overflow-y: auto;
     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 {
 .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 {
 .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 {
 .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 {
 .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;
     border-radius: 4px;
+    color: #c9d1d9;
+    font-size: 0.8rem;
+    margin: 0.2rem 0;
     overflow-x: auto;
     overflow-x: auto;
-    font-size: 0.85rem;
+    padding: 0.3rem 0.5rem;
     white-space: pre-wrap;
     white-space: pre-wrap;
     word-break: break-word;
     word-break: break-word;
 }
 }
-.event-pre.event-stderr {
-    color: var(--danger, #cf222e);
-}
 .event-pre.event-command {
 .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 {
 .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 { margin: 0.2rem 0; }
 .event-fold > summary {
 .event-fold > summary {
-    color: var(--ink-muted);
+    color: #8b949e;
     cursor: pointer;
     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-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 {
 .event-diff {
-    background: var(--surface-sunken);
-    border: 1px solid var(--line-soft);
+    background: #0d1117;
+    border: 1px solid #21262d;
     border-radius: 6px;
     border-radius: 6px;
     font-family: ui-monospace, "Cascadia Mono", monospace;
     font-family: ui-monospace, "Cascadia Mono", monospace;
     font-size: 0.8rem;
     font-size: 0.8rem;
@@ -1052,18 +1270,10 @@ dialog#quote-comment-dialog blockquote {
     white-space: pre-wrap;
     white-space: pre-wrap;
     word-break: break-word;
     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 feed = null;
             var moreButton = null;
             var moreButton = null;
             var stream = 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) {
             function kindClass(kind) {
                 return 'event-kind-' + String(kind || '').replace(/[^a-z0-9-]/g, '');
                 return 'event-kind-' + String(kind || '').replace(/[^a-z0-9-]/g, '');
@@ -243,6 +256,9 @@
             function resultSummary(evt, envelope) {
             function resultSummary(evt, envelope) {
                 var p = evt.payload || {};
                 var p = evt.payload || {};
                 var wrap = document.createElement('summary');
                 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'));
                 wrap.appendChild(document.createTextNode('result'));
                 var bits = [];
                 var bits = [];
                 if (envelope.exit_code !== undefined
                 if (envelope.exit_code !== undefined
@@ -315,11 +331,11 @@
                     // Empty stdout AND stderr. An error/note/
                     // Empty stdout AND stderr. An error/note/
                     // timeout member still said something, and a
                     // timeout member still said something, and a
                     // non-run_command tool's answer IS the
                     // 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)
                     var note = strText(envelope.error) || strText(envelope.note)
                         || (envelope.timeout ? 'timed out' : '');
                         || (envelope.timeout ? 'timed out' : '');
                     if (note.length > 0) {
                     if (note.length > 0) {
@@ -328,7 +344,10 @@
                         div.appendChild(notePre);
                         div.appendChild(notePre);
                     } else {
                     } else {
                         var extras = resultExtras(envelope);
                         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);
                             var compact = JSON.stringify(extras);
                             div.appendChild(preLine(compact.length <= 120
                             div.appendChild(preLine(compact.length <= 120
                                 ? compact : JSON.stringify(extras, null, 2)));
                                 ? compact : JSON.stringify(extras, null, 2)));
@@ -343,6 +362,114 @@
                 return div;
                 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
             // The tool call renders a TAILORED body per tool: the
             // head line always carries the tool and its target (a
             // head line always carries the tool and its target (a
             // $ command, a file path, a task id) and the payload
             // $ command, a file path, a task id) and the payload
@@ -699,7 +826,7 @@
 
 
             function eventNode(evt) {
             function eventNode(evt) {
                 var row = document.createElement('div');
                 var row = document.createElement('div');
-                row.className = 'event-row';
+                row.className = 'event-row ' + kindClass(evt.kind);
                 var head = document.createElement('p');
                 var head = document.createElement('p');
                 head.className = 'event-head';
                 head.className = 'event-head';
                 var badge = document.createElement('span');
                 var badge = document.createElement('span');
@@ -829,14 +956,24 @@
                 var card = document.createElement('div');
                 var card = document.createElement('div');
                 card.className = 'turn-card';
                 card.className = 'turn-card';
                 var head = document.createElement('p');
                 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
                 if (events[0].payload && events[0].payload.turn !== undefined
                         && events[0].payload.turn !== null) {
                         && 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);
                 card.appendChild(head);
                 events.forEach(function (evt) {
                 events.forEach(function (evt) {
                     card.appendChild(eventNode(evt));
                     card.appendChild(eventNode(evt));
@@ -864,7 +1001,7 @@
                 var visibleRows = state.events.filter(visible);
                 var visibleRows = state.events.filter(visible);
                 if (visibleRows.length === 0) {
                 if (visibleRows.length === 0) {
                     var empty = document.createElement('p');
                     var empty = document.createElement('p');
-                    empty.className = 'muted';
+                    empty.className = 'muted log-empty';
                     empty.textContent = state.events.length === 0
                     empty.textContent = state.events.length === 0
                         ? 'Waiting for the first events\u2026'
                         ? 'Waiting for the first events\u2026'
                         : 'No events match the filters.';
                         : 'No events match the filters.';
@@ -950,7 +1087,11 @@
                 var base = '/tasks/' + log.task_id;
                 var base = '/tasks/' + log.task_id;
                 stream = new EventSource(base + '/events/stream');
                 stream = new EventSource(base + '/events/stream');
                 stream.onmessage = onStreamMessage;
                 stream.onmessage = onStreamMessage;
+                stream.onopen = function () {
+                    setLive('live');
+                };
                 stream.onerror = function () {
                 stream.onerror = function () {
+                    setLive('retry');
                     // EventSource retries on its own (retry: 3s
                     // EventSource retries on its own (retry: 3s
                     // hint); the reconnect replays from the
                     // hint); the reconnect replays from the
                     // last seen id and ingest dedupes it.
                     // last seen id and ingest dedupes it.
@@ -1015,6 +1156,8 @@
                 if (!page || !page.exists) return;
                 if (!page || !page.exists) return;
                 feed = document.querySelector('[data-log-feed]');
                 feed = document.querySelector('[data-log-feed]');
                 moreButton = document.querySelector('[data-log-more]');
                 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]');
                 var header = document.querySelector('[data-log-title]');
                 if (header) {
                 if (header) {
                     header.textContent = '#' + page.task_id + ' \u2014 '
                     header.textContent = '#' + page.task_id + ' \u2014 '
@@ -1029,16 +1172,24 @@
 </head>
 </head>
 <body>
 <body>
     <div class="page log-page">
     <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" class="muted">No task exists with this id.</div>
         <div stm-if="task_log.exists">
         <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">
                 <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="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="results">results</button>
                     <button type="button" class="log-filter active" data-log-filter="comments">comments</button>
                     <button type="button" class="log-filter active" data-log-filter="comments">comments</button>
@@ -1048,7 +1199,7 @@
                 </div>
                 </div>
                 <button type="button" class="log-more" data-log-more hidden>Load older events</button>
                 <button type="button" class="log-more" data-log-more hidden>Load older events</button>
                 <div class="log-feed" data-log-feed>
                 <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>
                 </div>
             </section>
             </section>
         </div>
         </div>

+ 123 - 6
test/test_task_log_js.py

@@ -545,7 +545,9 @@ def result_summary(evt):
 
 
 def result_output(evt):
 def result_output(evt):
     """PORT of resultBody's text decisions -> (stdout_text,
     """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)
     envelope = result_envelope(evt)
     payload = evt.get("payload") or {}
     payload = evt.get("payload") or {}
     parsed = parse_maybe_json(payload.get("result"))
     parsed = parse_maybe_json(payload.get("result"))
@@ -568,12 +570,90 @@ def result_output(evt):
         if not note:
         if not note:
             remainder = result_extras(envelope)
             remainder = result_extras(envelope)
             if remainder:
             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)
     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):
 def args_without(args, skip):
     """PORT of argsWithout: every member except `skip`, pretty-
     """PORT of argsWithout: every member except `skip`, pretty-
     printed; '' when nothing is left."""
     printed; '' when nothing is left."""
@@ -1086,15 +1166,52 @@ class ToolResultRenderTests(unittest.TestCase):
             self.assertEqual((out, err, note), ("", "", ""))
             self.assertEqual((out, err, note), ("", "", ""))
             self.assertTrue(extras, inner)
             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": [
         inner = json.dumps({"ok": True, "tasks": [
             {"id": 6, "title": "UX notes", "status": "queued"},
             {"id": 6, "title": "UX notes", "status": "queued"},
             {"id": 5, "title": "Event log parse", "status": "working"}]})
             {"id": 5, "title": "Event log parse", "status": "working"}]})
         out, err, note, extras, no_output = result_output(self.evt(
         out, err, note, extras, no_output = result_output(self.evt(
             {"tool": "list_tasks", "result": inner}))
             {"tool": "list_tasks", "result": inner}))
         self.assertFalse(no_output)
         self.assertFalse(no_output)
-        self.assertIn('"tasks"', extras)
         self.assertIn("UX notes", 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):
     def test_run_command_with_empty_streams_is_still_no_output(self):
         """Only command plumbing present: the run genuinely said
         """Only command plumbing present: the run genuinely said