Răsfoiți Sursa

New task view

Billy Barrow 1 lună în urmă
părinte
comite
3d3a1c049f
3 a modificat fișierele cu 750 adăugiri și 55 ștergeri
  1. 38 0
      src/pages/app.css
  2. 380 23
      src/pages/task_log.html
  3. 332 32
      test/test_task_log_js.py

+ 38 - 0
src/pages/app.css

@@ -1017,6 +1017,44 @@ dialog#quote-comment-dialog blockquote {
 .event-result-body {
     margin-top: 0.3rem;
 }
+/* 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);
+}
+.event-fold { margin: 0.2rem 0; }
+.event-fold > summary {
+    color: var(--ink-muted);
+    cursor: pointer;
+    font-size: 0.85rem;
+}
+.event-fold[open] > summary { margin-bottom: 0.2rem; }
+.event-question { font-style: italic; }
+.event-usage {
+    color: var(--ink-muted);
+    font-size: 0.85rem;
+}
+.event-diff {
+    background: var(--surface-sunken);
+    border: 1px solid var(--line-soft);
+    border-radius: 6px;
+    font-family: ui-monospace, "Cascadia Mono", monospace;
+    font-size: 0.8rem;
+    margin: 0.2rem 0;
+    max-height: 24rem;
+    overflow: auto;
+    padding: 0.3rem 0;
+}
+.diff-line {
+    padding: 0 0.6rem;
+    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; }

+ 380 - 23
src/pages/task_log.html

@@ -51,8 +51,13 @@
     // members through parseMaybeJson first and falls back to
     // the raw string when it is not JSON - data is never
     // hidden. The command the agent ran must always be
-    // visible: tool_call renders it as its own line,
-    // command_output prefixes '$ <command>'. Results collapse
+    // visible: command_output prefixes '$ <command>', and each
+    // tool_call renders through a tailored body
+    // (tailoredCallBody) - run_command heads with '$ cmd', the
+    // file tools show paths with folded content,
+    // replace_in_file renders a -old/+new diff, the text tools
+    // render prose - with the generic JSON view as the
+    // backstop for unknown tools and truncated args. Results collapse
     // behind a summary carrying exit code, byte counts (from
     // the envelope when present), the truncated flag and
     // duration. Filters
@@ -338,20 +343,24 @@
                 return div;
             }
 
-            // The tool call renders the tool name and then the
-            // command (or every parsed argument) as pre lines -
-            // what the agent asked for must be visible at a
-            // glance, not only inside a payload dump.
+            // 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
+            // renders in the shape a human scans - a diff for
+            // replace_in_file, a folded blob for file content,
+            // prose for the text tools. A bespoke renderer answers
+            // null when its fields are missing (a truncated args
+            // string clamps mid-JSON and stops parsing); the
+            // generic view then shows the raw text so data is
+            // never hidden.
             function toolCallBody(p) {
                 var frag = document.createDocumentFragment();
                 var tool = strText(p.tool).length > 0 ? strText(p.tool) : 'tool';
                 var parsed = parseMaybeJson(p.args);
                 var args = isPlainObject(parsed) ? parsed : null;
-                var line = tool;
-                if (args && strText(args.command).length > 0) {
-                    line += ': ' + args.command;
-                }
-                var head = preLine(line);
+                var bespoke = args ? tailoredCallBody(tool, args) : null;
+                if (bespoke) return bespoke;
+                var head = preLine(tool);
                 head.className = 'event-pre event-command';
                 frag.appendChild(head);
                 if (args) {
@@ -368,6 +377,313 @@
                 return frag;
             }
 
+            // ---- tailored rendering helpers ----
+
+            function para(text, className) {
+                var node = document.createElement('p');
+                if (className) node.className = className;
+                node.textContent = text;
+                return node;
+            }
+
+            // The one-line tool head: the primary text plus muted
+            // detail bits separated by middle dots.
+            function headLine(text, bits) {
+                var pre = preLine(text);
+                pre.className = 'event-pre event-command';
+                if (bits && bits.length > 0) {
+                    var tail = document.createElement('span');
+                    tail.className = 'muted';
+                    tail.textContent = '  \u00b7 ' + bits.join(' \u00b7 ');
+                    pre.appendChild(tail);
+                }
+                return pre;
+            }
+
+            // A collapsed fold for long payloads (file content,
+            // plans, reports): the label carries the size so the
+            // feed stays scannable and the content is one click
+            // away.
+            function fold(label, build) {
+                var details = document.createElement('details');
+                details.className = 'event-fold';
+                var summary = document.createElement('summary');
+                summary.textContent = label;
+                details.appendChild(summary);
+                details.appendChild(build());
+                return details;
+            }
+
+            function contentFold(label, text) {
+                if (strText(text).length === 0) return null;
+                var body = text;
+                return fold(label + ' \u00b7 ' + fmtBytes(body.length), function () {
+                    return preLine(body);
+                });
+            }
+
+            // Integer with thousands separators (a plain loop, no
+            // locale dependence).
+            function fmtInt(n) {
+                var digits = String(Math.abs(Math.round(n)));
+                var out = '';
+                for (var i = 0; i < digits.length; i++) {
+                    if (i > 0 && (digits.length - i) % 3 === 0) out += ',';
+                    out += digits.charAt(i);
+                }
+                return (n < 0 ? '-' : '') + out;
+            }
+
+            // The first non-empty string member among the usual
+            // text field names - the prose of an event whose kind
+            // has no bespoke branch.
+            function pickText(p) {
+                var names = ['text', 'body', 'question', 'answer',
+                    'verdict', 'message', 'reason'];
+                for (var i = 0; i < names.length; i++) {
+                    if (strText(p[names[i]]).length > 0) return strText(p[names[i]]);
+                }
+                return '';
+            }
+
+            // ---- the replace_in_file diff ----
+            //
+            // Common leading/trailing lines become context
+            // (clamped to a couple of lines each side); the
+            // differing middle renders as -old/+new. A multi-hunk
+            // edit collapses into one hunk - an honest
+            // replacement view, never a wrong one. Oversized
+            // sides keep their head and tail around an ellipsis
+            // row.
+            var DIFF_CONTEXT = 2;
+            var DIFF_MAX_LINES = 400;
+
+            function diffRows(oldText, newText) {
+                var oldLines = oldText.split('\n');
+                var newLines = newText.split('\n');
+                var start = 0;
+                while (start < oldLines.length && start < newLines.length
+                        && oldLines[start] === newLines[start]) {
+                    start++;
+                }
+                var endOld = oldLines.length;
+                var endNew = newLines.length;
+                while (endOld > start && endNew > start
+                        && oldLines[endOld - 1] === newLines[endNew - 1]) {
+                    endOld--;
+                    endNew--;
+                }
+                var rows = [];
+                var i;
+                var hasMiddle = endOld > start || endNew > start;
+                if (hasMiddle) {
+                    for (i = Math.max(0, start - DIFF_CONTEXT); i < start; i++) {
+                        rows.push([' ', oldLines[i]]);
+                    }
+                    pushSide(rows, oldLines, start, endOld, '-');
+                    pushSide(rows, newLines, start, endNew, '+');
+                    for (i = endOld; i < Math.min(oldLines.length, endOld + DIFF_CONTEXT); i++) {
+                        rows.push([' ', oldLines[i]]);
+                    }
+                }
+                return rows;
+            }
+
+            function pushSide(rows, lines, begin, end, marker) {
+                var count = end - begin;
+                var half = Math.floor(DIFF_MAX_LINES / 2);
+                if (count <= DIFF_MAX_LINES) {
+                    for (var i = begin; i < end; i++) rows.push([marker, lines[i]]);
+                    return;
+                }
+                for (var j = begin; j < begin + half; j++) rows.push([marker, lines[j]]);
+                rows.push([marker, '... ' + (count - half * 2) + ' more lines ...']);
+                for (var k = end - half; k < end; k++) rows.push([marker, lines[k]]);
+            }
+
+            function diffBlock(oldText, newText) {
+                var rows = diffRows(oldText, newText);
+                if (rows.length === 0) return null;
+                var div = document.createElement('div');
+                div.className = 'event-diff';
+                rows.forEach(function (row) {
+                    var line = document.createElement('div');
+                    line.className = 'diff-line diff-'
+                        + (row[0] === '-' ? 'del' : (row[0] === '+' ? 'add' : 'ctx'));
+                    line.textContent = row[0] + ' ' + row[1];
+                    div.appendChild(line);
+                });
+                return div;
+            }
+
+            // ---- the bespoke tool bodies ----
+            //
+            // Each branch answers a DocumentFragment when it can
+            // render the call faithfully, null to fall back to the
+            // generic view.
+            function tailoredCallBody(tool, args) {
+                var frag = document.createDocumentFragment();
+                var path = strText(args.path);
+                var command = strText(args.command);
+                var question = strText(args.question);
+                var bits = [];
+                var node;
+
+                if (tool === 'run_command') {
+                    if (command.length === 0) return null;
+                    if (typeof args.timeout_ms === 'number') {
+                        bits.push('timeout ' + Math.round(args.timeout_ms / 1000) + 's');
+                    }
+                    frag.appendChild(headLine('$ ' + command, bits));
+                    return frag;
+                }
+                if (tool === 'read_file') {
+                    if (path.length === 0) return null;
+                    if (typeof args.offset === 'number' && args.offset > 1) {
+                        bits.push('from line ' + args.offset);
+                    }
+                    if (typeof args.limit === 'number') {
+                        bits.push(args.limit + ' lines');
+                    }
+                    frag.appendChild(headLine('read_file ' + path, bits));
+                    return frag;
+                }
+                if (tool === 'read_binary') {
+                    if (path.length === 0) return null;
+                    if (strText(args.representation).length > 0) {
+                        bits.push(strText(args.representation));
+                    }
+                    if (typeof args.offset === 'number' || typeof args.length === 'number') {
+                        bits.push('bytes ' + (args.offset || 0) + '-'
+                            + ((args.offset || 0) + (args.length || 4096) - 1));
+                    }
+                    frag.appendChild(headLine('read_binary ' + path, bits));
+                    return frag;
+                }
+                if (tool === 'write_file' || tool === 'overwrite_file') {
+                    if (path.length === 0 || strText(args.content).length === 0) return null;
+                    frag.appendChild(headLine(tool + ' ' + path,
+                        [fmtBytes(strText(args.content).length)]));
+                    node = contentFold('content', args.content);
+                    if (node) frag.appendChild(node);
+                    return frag;
+                }
+                if (tool === 'replace_in_file') {
+                    if (path.length === 0 || strText(args.old).length === 0) return null;
+                    if (typeof args.expected_count === 'number') {
+                        bits.push('expect ' + args.expected_count);
+                    }
+                    frag.appendChild(headLine('replace_in_file ' + path, bits));
+                    node = diffBlock(strText(args.old), strText(args.new));
+                    if (node) frag.appendChild(node);
+                    return frag;
+                }
+                if (tool === 'temp_doc') {
+                    if (strText(args.content).length === 0) return null;
+                    frag.appendChild(headLine('temp_doc',
+                        [fmtBytes(strText(args.content).length)]));
+                    node = contentFold('content', args.content);
+                    if (node) frag.appendChild(node);
+                    return frag;
+                }
+                if (tool === 'query_command' || tool === 'query_file' || tool === 'query_llm') {
+                    var prompt = question.length > 0 ? question : strText(args.prompt);
+                    if (tool === 'query_command' && command.length > 0) {
+                        frag.appendChild(headLine('$ ' + command));
+                    } else if (path.length > 0) {
+                        frag.appendChild(headLine(tool + ' ' + path));
+                    } else {
+                        frag.appendChild(headLine(tool));
+                    }
+                    if (prompt.length > 0) {
+                        frag.appendChild(para('Q: ' + prompt, 'event-question'));
+                    }
+                    return frag;
+                }
+                if (tool === 'create_task') {
+                    if (strText(args.title).length === 0) return null;
+                    if (strText(args.mode).length > 0) bits.push(strText(args.mode));
+                    if (strText(args.branch).length > 0) {
+                        bits.push('branch ' + strText(args.branch));
+                    }
+                    frag.appendChild(headLine('create_task: ' + strText(args.title), bits));
+                    if (strText(args.brief).length > 0) {
+                        node = contentFold('brief', args.brief);
+                        if (node) frag.appendChild(node);
+                    }
+                    return frag;
+                }
+                if (tool === 'enqueue_task' || tool === 'delete_task' || tool === 'get_task') {
+                    if (typeof args.task_id !== 'number') return null;
+                    frag.appendChild(headLine(tool + ' #' + args.task_id));
+                    return frag;
+                }
+                if (tool === 'list_goals') {
+                    if (strText(args.project).length > 0) {
+                        bits.push(strText(args.project));
+                    }
+                    if (typeof args.goal_id === 'number') {
+                        bits.push('#' + args.goal_id);
+                    }
+                    frag.appendChild(headLine('list_goals', bits));
+                    return frag;
+                }
+                if (tool === 'update_progress' || tool === 'add_comment' || tool === 'save_note') {
+                    var text = pickText(args);
+                    if (text.length === 0) return null;
+                    if (tool === 'save_note' && strText(args.project).length > 0) {
+                        bits.push(strText(args.project));
+                    }
+                    frag.appendChild(headLine(tool, bits));
+                    frag.appendChild(para(text));
+                    return frag;
+                }
+                if (tool === 'ask_question') {
+                    if (question.length === 0) return null;
+                    frag.appendChild(headLine('ask_question'));
+                    frag.appendChild(para('Q: ' + question, 'event-question'));
+                    if (Array.isArray(args.options) && args.options.length > 0) {
+                        var list = document.createElement('ul');
+                        args.options.forEach(function (option) {
+                            var item = document.createElement('li');
+                            item.textContent = String(option);
+                            list.appendChild(item);
+                        });
+                        frag.appendChild(list);
+                    }
+                    node = contentFold('context', args.context);
+                    if (node) frag.appendChild(node);
+                    return frag;
+                }
+                if (tool === 'submit_plan' || tool === 'submit_rfc' || tool === 'submit_report') {
+                    if (strText(args.markdown).length === 0) return null;
+                    frag.appendChild(headLine(tool));
+                    node = contentFold('markdown', args.markdown);
+                    if (node) frag.appendChild(node);
+                    return frag;
+                }
+                if (tool === 'finish') {
+                    if (strText(args.summary).length === 0) return null;
+                    if (strText(args.status).length > 0) bits.push(strText(args.status));
+                    frag.appendChild(headLine('finish', bits));
+                    node = contentFold('report', args.summary);
+                    if (node) frag.appendChild(node);
+                    return frag;
+                }
+                if (tool === 'yeehaw') {
+                    if (strText(args.message).length === 0) return null;
+                    frag.appendChild(headLine(tool));
+                    var whoop = document.createElement('p');
+                    var shout = document.createElement('span');
+                    shout.className = 'yeehaw-message';
+                    shout.textContent = strText(args.message);
+                    whoop.appendChild(shout);
+                    frag.appendChild(whoop);
+                    return frag;
+                }
+                return null;
+            }
+
             // Every argument except `skip`, pretty-printed; ''
             // when nothing else is left to show.
             function argsWithout(args, skip) {
@@ -400,7 +716,9 @@
                 body.className = 'event-body';
                 var p = evt.payload || {};
                 if (evt.kind === 'turn_text' && typeof p.text === 'string') {
-                    body.appendChild(preLine(p.text));
+                    var think = preLine(p.text);
+                    think.className = 'event-pre event-turn';
+                    body.appendChild(think);
                 } else if (evt.kind === 'tool_call') {
                     body.appendChild(toolCallBody(p));
                 } else if (evt.kind === 'tool_result') {
@@ -424,9 +742,9 @@
                         body.appendChild(preLine(text));
                     }
                 } else if (evt.kind === 'progress') {
-                    var para = document.createElement('p');
-                    para.textContent = p.text || '';
-                    body.appendChild(para);
+                    var note = document.createElement('p');
+                    note.textContent = p.text || '';
+                    body.appendChild(note);
                 } else if (evt.kind === 'comment') {
                     var quote = document.createElement('blockquote');
                     if (p.quote) {
@@ -451,14 +769,53 @@
                     whoop.appendChild(shout);
                     body.appendChild(whoop);
                 } else if (evt.kind === 'usage') {
-                    var ul = document.createElement('ul');
-                    Object.keys(p).forEach(function (key) {
-                        if (key === 'turn') return;
-                        var li = document.createElement('li');
-                        li.textContent = key + ': ' + p[key];
-                        ul.appendChild(li);
-                    });
-                    body.appendChild(ul);
+                    // One glancable line: model, token counts with
+                    // separators, cached share, wall clock - not a
+                    // key/value list to squint at. The daemon may
+                    // report either token count alone.
+                    var ubits = [];
+                    if (strText(p.model).length > 0) ubits.push(strText(p.model));
+                    var inTok = typeof p.prompt_tokens === 'number'
+                        ? fmtInt(p.prompt_tokens) + ' in' : '';
+                    var outTok = typeof p.completion_tokens === 'number'
+                        ? fmtInt(p.completion_tokens) + ' out' : '';
+                    if (inTok.length > 0 || outTok.length > 0) {
+                        ubits.push(inTok
+                            + (inTok.length > 0 && outTok.length > 0 ? ' / ' : '')
+                            + outTok + ' tok');
+                    }
+                    if (typeof p.cached_tokens === 'number') {
+                        ubits.push(fmtInt(p.cached_tokens) + ' cached');
+                    }
+                    if (typeof p.elapsed_ms === 'number') {
+                        ubits.push(fmtDuration(p.elapsed_ms));
+                    }
+                    if (ubits.length === 0) ubits.push('unreported');
+                    body.appendChild(para(ubits.join(' \u00b7 '), 'event-usage'));
+                } else if (evt.kind === 'state_change') {
+                    // {from, to, text} from the web's transitions:
+                    // the arrow line reads at a glance.
+                    var from = strText(p.from);
+                    var to = strText(p.to);
+                    if (from.length > 0 && to.length > 0) {
+                        body.appendChild(para(from + ' \u2192 ' + to));
+                    } else {
+                        body.appendChild(para(strText(p.text)
+                            || JSON.stringify(p)));
+                    }
+                } else if (evt.kind === 'lifecycle') {
+                    // {event, detail?} from the daemon's harness
+                    // observations.
+                    var what = strText(p.event);
+                    var detail = strText(p.detail);
+                    if (what.length > 0) {
+                        body.appendChild(para(detail.length > 0
+                            ? what + ' \u2014 ' + detail : what));
+                    } else {
+                        body.appendChild(para(pickText(p) || JSON.stringify(p)));
+                    }
+                } else if (evt.kind === 'answer' || evt.kind === 'verdict') {
+                    body.appendChild(para(pickText(p) || JSON.stringify(p)));
                 } else {
                     var generic = document.createElement('p');
                     generic.textContent = p.text || JSON.stringify(p);

+ 332 - 32
test/test_task_log_js.py

@@ -582,24 +582,181 @@ def args_without(args, skip):
 
 
 def tool_call_render(evt):
-    """PORT of toolCallBody -> the pre lines' texts, in order."""
+    """PORT of toolCallBody -> the rendered blocks as (kind, text)
+    pairs: ('head', line) for a command-styled head, ('pre', text)
+    for plain pre lines, ('para', text) for prose, ('fold', label)
+    for collapsed payloads, ('diff', '- old') / ('diff', '+ new')
+    for the replace_in_file diff. Bespoke bodies come from
+    tailored_blocks; None falls back to the generic view."""
     payload = evt.get("payload") or {}
     tool = str_text(payload.get("tool")) or "tool"
     parsed = parse_maybe_json(payload.get("args"))
     args = parsed if is_plain_object(parsed) else None
-    line = tool
-    if args and str_text(args.get("command")):
-        line += ": " + args["command"]
-    lines = [line]
+    if args is not None:
+        blocks = tailored_blocks(tool, args)
+        if blocks is not None:
+            return blocks
+    blocks = [("head", tool)]
     if args is not None:
         rest = args_without(args, "command")
         if rest:
-            lines.append(rest)
+            blocks.append(("pre", rest))
     elif str_text(payload.get("args")):
-        lines.append(payload["args"])
+        blocks.append(("pre", payload["args"]))
     elif payload.get("args") is not None and not isinstance(payload.get("args"), str):
-        lines.append(json.dumps(payload["args"], indent=2))
-    return lines
+        blocks.append(("pre", json.dumps(payload["args"], indent=2)))
+    return blocks
+
+
+def fmt_int(n):
+    """PORT of fmtInt: thousands separators without locale."""
+    digits = str(abs(int(round(n))))
+    out = ""
+    for i, char in enumerate(digits):
+        if i > 0 and (len(digits) - i) % 3 == 0:
+            out += ","
+        out += char
+    return ("-" if n < 0 else "") + out
+
+
+def usage_line(payload):
+    """PORT of eventNode's usage branch -> the one-line text. The
+    daemon may report either token count alone."""
+    bits = []
+    if str_text(payload.get("model")):
+        bits.append(payload["model"])
+    in_tok = "%s in" % fmt_int(payload["prompt_tokens"]) \
+        if isinstance(payload.get("prompt_tokens"), int) else ""
+    out_tok = "%s out" % fmt_int(payload["completion_tokens"]) \
+        if isinstance(payload.get("completion_tokens"), int) else ""
+    if in_tok or out_tok:
+        bits.append(in_tok + (" / " if in_tok and out_tok else "") + out_tok + " tok")
+    if isinstance(payload.get("cached_tokens"), int):
+        bits.append("%s cached" % fmt_int(payload["cached_tokens"]))
+    if isinstance(payload.get("elapsed_ms"), int):
+        bits.append(fmt_duration(payload["elapsed_ms"]))
+    return " \u00b7 ".join(bits) if bits else "unreported"
+
+
+def state_change_line(payload):
+    """PORT of eventNode's state_change branch."""
+    from_status = str_text(payload.get("from"))
+    to_status = str_text(payload.get("to"))
+    if from_status and to_status:
+        return from_status + " \u2192 " + to_status
+    return str_text(payload.get("text")) or json.dumps(payload)
+
+
+DIFF_CONTEXT = 2
+DIFF_MAX_LINES = 400
+
+
+def _push_side(rows, lines, begin, end, marker):
+    """PORT of pushSide."""
+    count = end - begin
+    half = DIFF_MAX_LINES // 2
+    if count <= DIFF_MAX_LINES:
+        for i in range(begin, end):
+            rows.append([marker, lines[i]])
+        return
+    for j in range(begin, begin + half):
+        rows.append([marker, lines[j]])
+    rows.append([marker, "... %d more lines ..." % (count - half * 2)])
+    for k in range(end - half, end):
+        rows.append([marker, lines[k]])
+
+
+def diff_rows(old_text, new_text):
+    """PORT of diffRows: common head/tail become clamped context,
+    the differing middle renders as -old/+new."""
+    old_lines = old_text.split("\n")
+    new_lines = new_text.split("\n")
+    start = 0
+    while start < len(old_lines) and start < len(new_lines) \
+            and old_lines[start] == new_lines[start]:
+        start += 1
+    end_old, end_new = len(old_lines), len(new_lines)
+    while end_old > start and end_new > start \
+            and old_lines[end_old - 1] == new_lines[end_new - 1]:
+        end_old -= 1
+        end_new -= 1
+    rows = []
+    has_middle = end_old > start or end_new > start
+    if has_middle:
+        for i in range(max(0, start - DIFF_CONTEXT), start):
+            rows.append([" ", old_lines[i]])
+        _push_side(rows, old_lines, start, end_old, "-")
+        _push_side(rows, new_lines, start, end_new, "+")
+        for i in range(end_old, min(len(old_lines), end_old + DIFF_CONTEXT)):
+            rows.append([" ", old_lines[i]])
+    return rows
+
+
+def tailored_blocks(tool, args):
+    """PORT of tailoredCallBody -> the bespoke blocks, or None to
+    fall back to the generic JSON view (fields missing - e.g. a
+    truncated args string that clamped mid-JSON)."""
+    path = str_text(args.get("path"))
+    command = str_text(args.get("command"))
+    question = str_text(args.get("question"))
+    bits = []
+
+    def head(text, more=None):
+        block = ("head", text)
+        if more:
+            block = ("head", text + "  \u00b7 " + " \u00b7 ".join(more))
+        return block
+
+    if tool == "run_command":
+        if not command:
+            return None
+        if isinstance(args.get("timeout_ms"), int):
+            bits.append("timeout %ds" % round(args["timeout_ms"] / 1000))
+        return [head("$ " + command, bits)]
+    if tool == "read_file":
+        if not path:
+            return None
+        if isinstance(args.get("offset"), int) and args["offset"] > 1:
+            bits.append("from line %d" % args["offset"])
+        if isinstance(args.get("limit"), int):
+            bits.append("%d lines" % args["limit"])
+        return [head("read_file " + path, bits)]
+    if tool in ("write_file", "overwrite_file"):
+        content = str_text(args.get("content"))
+        if not path or not content:
+            return None
+        return [head(tool + " " + path, [fmt_bytes(len(content))]),
+                ("fold", "content \u00b7 " + fmt_bytes(len(content)))]
+    if tool == "replace_in_file":
+        old = str_text(args.get("old"))
+        if not path or not old:
+            return None
+        if isinstance(args.get("expected_count"), int):
+            bits.append("expect %d" % args["expected_count"])
+        blocks = [head("replace_in_file " + path, bits)]
+        rows = diff_rows(old, str_text(args.get("new")))
+        if rows:
+            blocks.extend(("diff", marker + " " + text) for marker, text in rows)
+        return blocks
+    if tool in ("enqueue_task", "delete_task", "get_task"):
+        if not isinstance(args.get("task_id"), int):
+            return None
+        return [head(tool + " #" + str(args["task_id"]))]
+    if tool in ("update_progress", "add_comment", "save_note"):
+        text = str_text(args.get("text")) or str_text(args.get("body"))
+        if not text:
+            return None
+        if tool == "save_note" and str_text(args.get("project")):
+            bits.append(str_text(args.get("project")))
+        return [head(tool, bits), ("para", text)]
+    if tool == "ask_question":
+        if not question:
+            return None
+        blocks = [head("ask_question"), ("para", "Q: " + question)]
+        if isinstance(args.get("options"), list) and args["options"]:
+            blocks.append(("options", [str(o) for o in args["options"]]))
+        return blocks
+    return None
 
 
 def command_output_render(evt):
@@ -672,6 +829,21 @@ FIXTURE_OBJECT_CALL = {"turn": 1, "tool": "run_command",
 FIXTURE_OBJECT_RESULT = {"turn": 1, "tool": "run_command",
                          "stdout": "hello\n", "exit_code": 0}
 
+# Tailored-rendering fixtures (plan 18): the file tools' arg shapes
+# straight off the daemon's tool schemas.
+FIXTURE_REPLACE_CALL = {"turn": 40, "tool": "replace_in_file", "args": {
+    "path": "src/pages/app.css",
+    "old": ".event-turn {\n    border-left: 3px solid #ddf4ff;\n}",
+    "new": ".event-turn {\n    border-left: 3px solid #ddf4ff;\n    color: var(--ink-muted);\n}",
+    "expected_count": 1}}
+FIXTURE_WRITE_CALL = {"turn": 41, "tool": "write_file", "args": {
+    "path": "notes.md", "content": "# Notes\n- first\n- second"}}
+# Args clamped mid-JSON by the daemon's display clamp: parsing
+# fails, so the bespoke renderer must step aside and the generic
+# view shows the raw text.
+FIXTURE_TRUNCATED_ARGS = {"turn": 42, "tool": "write_file", "args":
+                          '{"path":"big.bin","content":"AAA'}
+
 
 class PayloadParserTests(unittest.TestCase):
 
@@ -689,42 +861,164 @@ class PayloadParserTests(unittest.TestCase):
 
 
 class ToolCallRenderTests(unittest.TestCase):
-    """The command the agent ran must surface in EVERY render path."""
+    """The command the agent ran must surface in EVERY render path,
+    and each known tool renders through its tailored body."""
 
     def evt(self, payload):
         return {"kind": "tool_call", "payload": payload}
 
-    def test_run_command_call_surfaces_the_command(self):
-        lines = tool_call_render(self.evt(FIXTURE_RUN_COMMAND_CALL))
-        self.assertEqual(lines[0], "run_command: cd /workspace && ls")
-
-    def test_update_progress_call_renders_its_args(self):
-        """Non-run_command tools must show their args, not just the
-        tool name (the old renderer showed only the name)."""
-        lines = tool_call_render(self.evt(FIXTURE_UPDATE_PROGRESS_CALL))
-        self.assertEqual(lines[0], "update_progress")
-        self.assertEqual(len(lines), 2)
-        self.assertIn("Root-caused the SSE listener", lines[1])
-        self.assertIn('"text"', lines[1])
+    def test_run_command_call_heads_with_the_dollar(self):
+        blocks = tool_call_render(self.evt(FIXTURE_RUN_COMMAND_CALL))
+        self.assertEqual(blocks[0], ("head", "$ cd /workspace && ls"))
+
+    def test_run_command_timeout_rides_the_head(self):
+        blocks = tool_call_render(self.evt({"tool": "run_command", "args":
+                                            {"command": "make", "timeout_ms": 900000}}))
+        self.assertEqual(blocks[0],
+                         ("head", "$ make  \u00b7 timeout 900s"))
+
+    def test_update_progress_call_renders_prose_not_json(self):
+        """The old renderer dumped {"text": ...} as JSON; the
+        tailored body renders the text itself."""
+        blocks = tool_call_render(self.evt(FIXTURE_UPDATE_PROGRESS_CALL))
+        self.assertEqual(blocks[0], ("head", "update_progress"))
+        self.assertEqual(blocks[1],
+                         ("para", "Root-caused the SSE listener; next: the payload parse"))
+        self.assertNotIn('"text"', json.dumps(blocks))
 
     def test_enqueue_call_renders_the_id(self):
-        lines = tool_call_render(self.evt(FIXTURE_ENQUEUE_CALL))
-        self.assertEqual(lines[0], "enqueue_task")
-        self.assertIn('"task_id": 3', lines[1])
+        blocks = tool_call_render(self.evt(FIXTURE_ENQUEUE_CALL))
+        self.assertEqual(blocks, [("head", "enqueue_task #3")])
+
+    def test_replace_in_file_renders_a_diff(self):
+        blocks = tool_call_render(self.evt(FIXTURE_REPLACE_CALL))
+        self.assertEqual(blocks[0],
+                         ("head", "replace_in_file src/pages/app.css  \u00b7 expect 1"))
+        diff_texts = [text for kind, text in blocks if kind == "diff"]
+        joined = " ".join(diff_texts)
+        self.assertIn("  .event-turn {", joined)
+        # The untouched border line is context; only the added color
+        # line is an addition - no phantom removals.
+        self.assertIn("      border-left: 3px solid #ddf4ff;", joined)
+        self.assertIn("+     color: var(--ink-muted);", joined)
+        self.assertIn("  }", joined)
+        self.assertFalse([t for t in diff_texts if t.startswith("- ")],
+                         "an unchanged line must never render as a removal")
+
+    def test_write_file_folds_its_content(self):
+        blocks = tool_call_render(self.evt(FIXTURE_WRITE_CALL))
+        self.assertEqual(blocks[0], ("head", "write_file notes.md  \u00b7 24 B"))
+        self.assertEqual(blocks[1], ("fold", "content \u00b7 24 B"))
+
+    def test_truncated_args_fall_back_to_the_raw_view(self):
+        blocks = tool_call_render(self.evt(FIXTURE_TRUNCATED_ARGS))
+        self.assertEqual(blocks[0], ("head", "write_file"))
+        self.assertEqual(blocks[1][0], "pre")
+        self.assertIn('"path":"big.bin"', blocks[1][1])
+
+    def test_unknown_tool_falls_back_to_json_args(self):
+        blocks = tool_call_render(self.evt(
+            {"tool": "brand_new_tool", "args": {"deep": {"a": 1}}}))
+        self.assertEqual(blocks[0], ("head", "brand_new_tool"))
+        self.assertIn('"deep"', blocks[1][1])
 
     def test_object_shaped_call_still_works(self):
-        lines = tool_call_render(self.evt(FIXTURE_OBJECT_CALL))
-        self.assertEqual(lines[0], "run_command: echo hello")
-        self.assertEqual(len(lines), 1)
+        blocks = tool_call_render(self.evt(FIXTURE_OBJECT_CALL))
+        self.assertEqual(blocks, [("head", "$ echo hello")])
 
     def test_non_json_args_render_raw(self):
-        lines = tool_call_render(self.evt(
+        blocks = tool_call_render(self.evt(
             {"tool": "run_command", "args": "not json at all"}))
-        self.assertEqual(lines, ["run_command", "not json at all"])
+        self.assertEqual(blocks,
+                         [("head", "run_command"), ("pre", "not json at all")])
 
     def test_no_args_at_all_renders_the_tool_name(self):
-        lines = tool_call_render(self.evt({"tool": "list_tasks"}))
-        self.assertEqual(lines, ["list_tasks"])
+        blocks = tool_call_render(self.evt({"tool": "list_tasks"}))
+        self.assertEqual(blocks, [("head", "list_tasks")])
+
+    def test_bespoke_missing_fields_step_aside(self):
+        """A replace_in_file without its old string (clamped args
+        that still parse) falls back rather than lying."""
+        blocks = tool_call_render(self.evt(
+            {"tool": "replace_in_file", "args": {"path": "x.c"}}))
+        self.assertEqual(blocks[0], ("head", "replace_in_file"))
+        self.assertEqual(blocks[1][0], "pre")
+
+
+class DiffRowTests(unittest.TestCase):
+    """The replace_in_file diff: context clamping and honest
+    replacement semantics."""
+
+    def test_single_line_swap(self):
+        rows = diff_rows("alpha\nbeta\ngamma", "alpha\nBETA\ngamma")
+        self.assertEqual(rows, [[" ", "alpha"], ["-", "beta"],
+                                ["+", "BETA"], [" ", "gamma"]])
+
+    def test_context_is_clamped_to_two_lines_per_side(self):
+        old = "\n".join(["ctx"] * 10 + ["old-line"] + ["ctx"] * 10)
+        new = "\n".join(["ctx"] * 10 + ["new-line"] + ["ctx"] * 10)
+        rows = diff_rows(old, new)
+        context = [text for marker, text in rows if marker == " "]
+        self.assertEqual(context, ["ctx"] * 4)
+
+    def test_whole_file_rewrite_shows_no_context(self):
+        rows = diff_rows("a\nb\nc", "x\ny\nz")
+        self.assertEqual([m for m, _ in rows], ["-", "-", "-", "+", "+", "+"])
+
+    def test_deletion_only_keeps_its_context(self):
+        rows = diff_rows("keep\ndrop\nkeep", "keep\nkeep")
+        self.assertEqual(rows, [[" ", "keep"], ["-", "drop"], [" ", "keep"]])
+
+    def test_empty_new_text(self):
+        rows = diff_rows("line", "")
+        self.assertEqual(rows, [["-", "line"], ["+", ""]])
+
+    def test_oversized_side_keeps_head_and_tail(self):
+        old = "\n".join("line-%04d" % i for i in range(1000))
+        rows = diff_rows(old, "changed")
+        dels = [text for marker, text in rows if marker == "-"]
+        self.assertEqual(len(dels), 401)
+        self.assertIn("more lines", dels[200])
+        self.assertEqual(dels[0], "line-0000")
+        self.assertEqual(dels[-1], "line-0999")
+
+    def test_identical_text_renders_nothing(self):
+        self.assertEqual(diff_rows("same\nsame", "same\nsame"), [])
+
+
+class UsageLineTests(unittest.TestCase):
+
+    def test_full_payload_is_one_line(self):
+        line = usage_line({"model": "glm-4.7", "prompt_tokens": 45200000,
+                           "completion_tokens": 1100000, "cached_tokens": 4500,
+                           "elapsed_ms": 3200})
+        self.assertEqual(line,
+                         "glm-4.7 \u00b7 45,200,000 in / 1,100,000 out tok "
+                         "\u00b7 4,500 cached \u00b7 3.2 s")
+
+    def test_unreported_when_no_numbers(self):
+        self.assertEqual(usage_line({}), "unreported")
+
+    def test_partial_payload_skips_missing_members(self):
+        line = usage_line({"completion_tokens": 5, "elapsed_ms": 40})
+        self.assertEqual(line, "5 out tok \u00b7 40 ms")
+
+
+class StateChangeLineTests(unittest.TestCase):
+
+    def test_from_to_arrow(self):
+        self.assertEqual(
+            state_change_line({"from": "working", "to": "blocked",
+                               "text": "Status \u2192 blocked"}),
+            "working \u2192 blocked")
+
+    def test_text_fallback(self):
+        self.assertEqual(
+            state_change_line({"text": "Goal created (branch x)"}),
+            "Goal created (branch x)")
+
+    def test_json_fallback(self):
+        self.assertEqual(state_change_line({}), "{}")
 
 
 class ToolResultRenderTests(unittest.TestCase):
@@ -920,6 +1214,12 @@ class RendererSourceTests(unittest.TestCase):
         self.assertIn("'yeehaw-message'", self.body)
         self.assertIn("strText(p.message)", self.body)
 
+    def test_tailored_renderers_present(self):
+        self.assertIn("function tailoredCallBody(tool, args) {", self.body)
+        self.assertIn("function diffRows(oldText, newText) {", self.body)
+        self.assertIn("'event-diff'", self.body)
+        self.assertIn("tailoredCallBody(tool, args)", self.body)
+
 
 if __name__ == "__main__":
     unittest.main(verbosity=2)