agent-ui: real fixed-width icon column for terminal rows

Replaces the first-character-glyph + negative-text-indent trick (which let a
wide emoji or a leading disclosure caret knock the icon out of column) with a
genuine icon cell.

terminal.js: row() / details() / detailsDiff() take an optional `icon` that
goes in a fixed-width `.row-glyph` element (inline-block, 1.4em). Details
summaries wrap their text in a `.summary-text` span; the disclosure caret
moves to `.summary-text::before` so it leads the text, not the icon — keeping
the icon in the shared column. terminal.css carries the cell + caret rules.

app.js passes the per-tool emoji as `icon` for the flat tool-use row and every
expandable tool summary (Write/Edit/send/ask/answer/bash) plus the 💭 thinking
row, instead of string-prefixing it. A details `🖥️` now lines up under a flat
row's `🧠` regardless of emoji width. Doc: terminal-rendering.md layout
contract updated. Closes #1844.
This commit is contained in:
iris 2026-06-22 00:38:00 +02:00 committed by mara
commit 1c47bd4333
4 changed files with 106 additions and 50 deletions

View file

@ -29,11 +29,14 @@
//
// Renderers receive (ev, api) where api exposes:
//
// api.row(cls, text) → appends a flat <div class="row cls">
// api.details(cls, summary, body) → appends <details class="row cls">
// with a <pre.tool-body>
// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by
// leading "+ " / "- " prefix
// api.row(cls, text, icon?) → appends a flat <div class="row cls">;
// optional `icon` lands in a
// fixed-width `.row-glyph` cell
// api.details(cls, summary, body, icon?) → appends <details class="row cls">
// with a <pre.tool-body>; `icon` (if
// given) shares the `.row-glyph` column
// api.detailsDiff(cls, summary, body, icon?) → ditto but body is
// line-coloured by leading "+ "/"- "
// api.placeholder(text) → replaces log content with a single
// muted "(placeholder)" row, cleared
// on the next real row
@ -222,24 +225,47 @@ export function create(opts) {
log.appendChild(e);
placeholderEl = e;
}
function row(cls, text) {
// A leading icon (`→ ← 🧠 🖥️ …`) goes in a fixed-width `.row-glyph`
// element so every row's icon lands in one column regardless of the
// glyph's rendered width (emoji vary; some carry variation selectors).
// Optional: callers that pass no `icon` keep the bare first-character
// prefix the older rows rely on.
function glyphSpan(icon) {
const g = document.createElement('span');
g.className = 'row-glyph';
g.textContent = icon;
return g;
}
// Build a <summary> whose icon (if any) sits in the shared `.row-glyph`
// column and whose text lives in a `.summary-text` span — the disclosure
// caret (CSS `.summary-text::before`) then leads the text, not the icon,
// so the icon stays aligned with flat-row icons.
function buildSummary(summary, icon) {
const s = document.createElement('summary');
if (icon != null && icon !== '') s.appendChild(glyphSpan(icon));
const st = document.createElement('span');
st.className = 'summary-text';
st.textContent = summary;
s.appendChild(st);
return s;
}
function row(cls, text, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const e = document.createElement('div');
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
e.appendChild(linkify(text));
log.appendChild(e);
afterAppend(wasNearBottom);
return e;
}
function details(cls, summary, body) {
function details(cls, summary, body, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const s = document.createElement('summary');
s.textContent = summary;
d.appendChild(s);
d.appendChild(buildSummary(summary, icon));
const pre = document.createElement('pre');
pre.className = 'tool-body';
pre.appendChild(linkify(body));
@ -248,14 +274,12 @@ export function create(opts) {
afterAppend(wasNearBottom);
return d;
}
function detailsDiff(cls, summary, body) {
function detailsDiff(cls, summary, body, icon) {
clearPlaceholder();
const wasNearBottom = isNearBottom();
const d = document.createElement('details');
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
const s = document.createElement('summary');
s.textContent = summary;
d.appendChild(s);
d.appendChild(buildSummary(summary, icon));
const pre = document.createElement('pre');
pre.className = 'tool-body diff-body';
for (const line of String(body).split('\n')) {