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

@ -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));
}
}
}