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

@ -13,17 +13,32 @@ event kind today. Source of truth lives in
Every row — flat `<div class="row …">` and expandable
`<details class="row …">` alike — shares one prefix column.
The mechanism is `padding-left + negative text-indent` on
`.live .row`: the row's first character (the prefix glyph)
gets pulled back into the column at ~0.5em, and wrapped
continuation lines hang under the body, not under the glyph.
`.live .row`: the row's first inline box gets pulled back
into the column at ~0.5em, and wrapped continuation lines
hang under the body, not under the glyph.
`<details>` summaries inherit those metrics. The disclosure
marker (`▸` / `▾`) is supplied by CSS `summary::before` so it
lands in the same column as flat-row glyphs. To make that
work the JS-side summary text **does not** include a
directional `→` / `←` — the row's colour (cyan = outbound,
muted = inbound) carries the direction, and the prefix
column never has to fit two glyphs side-by-side.
Rows that carry an icon (the per-tool emoji, `🧠`/`💭`
thinking, etc.) pass it as the `icon` argument to
`row()` / `details()` / `detailsDiff()`, which puts it in a
fixed-width `.row-glyph` cell (`display: inline-block;
width: 1.4em`) rather than as a bare first character. The
constant cell width means every icon's left edge lines up in
the column regardless of the glyph's rendered width (emoji
differ; some carry a variation selector) — a flat row's `🧠`
and a `details` summary's `🖥️` align. Rows with a plain
single-char glyph (`◆ · ! ←`) still pass it inline; it lands
at the same ~0.5em left edge.
`<details>` summaries inherit those metrics. The icon (when
present) sits in the `.row-glyph` cell; the summary text lives
in a `.summary-text` span and the disclosure caret (`▸` / `▾`)
is supplied by CSS `.summary-text::before` so it **leads the
text, not the icon** — a leading caret on the icon would push
it out of the shared column. Icon-less summaries have no
`.row-glyph`, so the caret falls into the prefix column like
the old directional glyph. The summary text carries no
`→` / `←`; the row colour (cyan = outbound, muted = inbound)
carries the direction.
Child blocks inside a row (the `.md` markdown wrapper, an
inner `<details>`) get `text-indent: 0` so their content

View file

@ -1439,8 +1439,8 @@ window.marked = marked;
// Used by send / ask / answer tool_use renderers and by `recv`
// tool_result so message-bearing rows show their content inline
// without an extra click.
function detailsOpenMd(api, cls, summary, body) {
const d = api.details(cls, summary, '');
function detailsOpenMd(api, cls, summary, body, icon) {
const d = api.details(cls, summary, '', icon);
d.open = true;
const pre = d.querySelector('pre.tool-body');
if (pre) {
@ -1592,13 +1592,13 @@ window.marked = marked;
+ '\n'
+ newLines.map(l => '+ ' + l).join('\n');
}
// Summaries on expandable rows omit the row's directional glyph
// (`→`) — the disclosure marker (`▸/▾`) from CSS sits in the
// prefix column for every row kind, and the row's cyan colour
// already signals "outbound tool".
const summary = toolIcon(name) + ' ' + name + ' ' + path + ' · '
// The tool icon goes in the shared `.row-glyph` cell (4th arg) so it
// lines up with flat-row icons; the summary text carries no
// directional `→` (the row's cyan colour signals "outbound tool" and
// the CSS disclosure caret leads the text).
const summary = name + ' ' + path + ' · '
+ (minus ? '-' + minus + ' ' : '') + '+' + plus;
return api.detailsDiff('tool-use', summary, body);
return api.detailsDiff('tool-use', summary, body, toolIcon(name));
}
// Message-bearing tools render default-open with a markdown body so
// the operator sees the content without an extra click. send / ask
@ -1608,16 +1608,16 @@ window.marked = marked;
const body = String(input.body || '');
const lines = body.split('\n').length;
return detailsOpenMd(api, 'tool-use',
toolIcon(name) + ' send → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
body);
'send → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
body, toolIcon(name));
}
if (name === 'mcp__hyperhive__ask') {
const to = input.to || 'operator';
const q = String(input.question || '');
const lines = q.split('\n').length;
const d = detailsOpenMd(api, 'tool-use',
toolIcon(name) + ' ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
q);
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
q, toolIcon(name));
// When the ask targets the operator, mount an inline answer
// slot in the live terminal — see docs/web-ui.md::Per-agent
// page (Ask → operator inline-answer binding) for the slot
@ -1646,8 +1646,8 @@ window.marked = marked;
const a = String(input.answer || '');
const lines = a.split('\n').length;
return detailsOpenMd(api, 'tool-use',
toolIcon(name) + ' answer #' + id + (lines > 1 ? ` · ${lines}L` : ''),
a);
'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''),
a, toolIcon(name));
}
// Bash task runner — show full command in an expandable pre block so
// multi-line scripts are readable. Summary uses the first line so the
@ -1655,8 +1655,8 @@ window.marked = marked;
if (name === 'mcp__bash__run') {
const cmd = String(input.cmd || '');
const firstLine = cmd.split('\n')[0];
const summary = toolIcon(name) + ' run* $ ' + trim(firstLine.trim(), 72);
return api.details('tool-use', summary, '$ ' + cmd);
const summary = 'run* $ ' + trim(firstLine.trim(), 72);
return api.details('tool-use', summary, '$ ' + cmd, toolIcon(name));
}
return null;
}
@ -1803,12 +1803,12 @@ window.marked = marked;
}
else if (c.type === 'thinking') {
const txt = (c.thinking || c.text || '').trim();
api.row('thinking', txt ? '💭 ' + txt : '💭 thinking …');
api.row('thinking', txt || 'thinking …', '💭');
}
else if (c.type === 'tool_use') {
if (c.id && c.name) toolNameById.set(c.id, c.name);
if (!renderRichToolUse(c, api)) {
api.row('tool-use', toolIcon(c.name) + ' ' + fmtToolUse(c));
api.row('tool-use', fmtToolUse(c), toolIcon(c.name));
}
}
}

View file

@ -84,6 +84,19 @@
margin: 0.1em 0;
}
.live .row + .row { border-top: 0; }
/* Fixed-width icon column. Rows built with an `icon` (see terminal.js
`row()` / `details()`) put it in a `.row-glyph` element instead of as a
bare first character. `inline-block` with a fixed `width` means the icon
occupies one constant-width cell regardless of the glyph's rendered width
(emoji differ; some carry a variation selector), so every icon's left edge
lines up a flat-row `🧠` and a `details` summary's `🖥` share the column.
It's the first inline box, so the row's `text-indent: -1.4em` pulls it into
the reserved prefix slot exactly like a bare glyph; the following text then
starts at the `padding-left` (1.9em) where wrapped lines also hang. */
.live .row-glyph {
display: inline-block;
width: 1.4em;
}
/* Row-kind colours. Pages register renderers that emit these classes;
any class no page emits is just dead CSS, which is fine. Turn-framing
classes carry their signal entirely on the coloured border-left rule
@ -189,11 +202,15 @@
opacity: 0.6;
}
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
negative text-indent) so the disclosure glyph (` / `) lands in
exactly the same column as flat-row prefix glyphs (` · `).
Summary text omits the per-row directional glyph (the row colour
already carries cyan = outbound tool, muted = inbound result) so
the prefix column doesn't have to fit two glyphs side-by-side. */
negative text-indent). The summary's icon (when present) sits in the
shared `.row-glyph` column same cell as a flat row's icon so a
`details` summary's `🖥️` lines up under a flat row's `🧠`. The
disclosure caret (` / `) leads the `.summary-text` (not the icon) via
`::before`, so it sits where the summary text starts rather than shoving
the icon out of the prefix column. Icon-less summaries have no `.row-glyph`,
so the caret falls back into the prefix column like the old directional
glyph. The summary text carries no ` / `; the row colour (cyan =
outbound tool, muted = inbound result) carries the direction. */
details.row {
white-space: normal;
}
@ -203,11 +220,11 @@ details.row > summary {
white-space: pre-wrap;
word-break: break-word;
}
details.row > summary::before {
details.row > summary > .summary-text::before {
content: '▸ ';
color: inherit;
}
details.row[open] > summary::before { content: '▾ '; }
details.row[open] > summary > .summary-text::before { content: '▾ '; }
details.row > pre.diff-body,
details.row > pre.tool-body {
margin: 0.3em 0 0.4em 0;

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')) {