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:
parent
0c48caff1e
commit
1c47bd4333
4 changed files with 106 additions and 50 deletions
|
|
@ -13,17 +13,32 @@ event kind today. Source of truth lives in
|
||||||
Every row — flat `<div class="row …">` and expandable
|
Every row — flat `<div class="row …">` and expandable
|
||||||
`<details class="row …">` alike — shares one prefix column.
|
`<details class="row …">` alike — shares one prefix column.
|
||||||
The mechanism is `padding-left + negative text-indent` on
|
The mechanism is `padding-left + negative text-indent` on
|
||||||
`.live .row`: the row's first character (the prefix glyph)
|
`.live .row`: the row's first inline box gets pulled back
|
||||||
gets pulled back into the column at ~0.5em, and wrapped
|
into the column at ~0.5em, and wrapped continuation lines
|
||||||
continuation lines hang under the body, not under the glyph.
|
hang under the body, not under the glyph.
|
||||||
|
|
||||||
`<details>` summaries inherit those metrics. The disclosure
|
Rows that carry an icon (the per-tool emoji, `🧠`/`💭`
|
||||||
marker (`▸` / `▾`) is supplied by CSS `summary::before` so it
|
thinking, etc.) pass it as the `icon` argument to
|
||||||
lands in the same column as flat-row glyphs. To make that
|
`row()` / `details()` / `detailsDiff()`, which puts it in a
|
||||||
work the JS-side summary text **does not** include a
|
fixed-width `.row-glyph` cell (`display: inline-block;
|
||||||
directional `→` / `←` — the row's colour (cyan = outbound,
|
width: 1.4em`) rather than as a bare first character. The
|
||||||
muted = inbound) carries the direction, and the prefix
|
constant cell width means every icon's left edge lines up in
|
||||||
column never has to fit two glyphs side-by-side.
|
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
|
Child blocks inside a row (the `.md` markdown wrapper, an
|
||||||
inner `<details>`) get `text-indent: 0` so their content
|
inner `<details>`) get `text-indent: 0` so their content
|
||||||
|
|
|
||||||
|
|
@ -1439,8 +1439,8 @@ window.marked = marked;
|
||||||
// Used by send / ask / answer tool_use renderers and by `recv`
|
// Used by send / ask / answer tool_use renderers and by `recv`
|
||||||
// tool_result so message-bearing rows show their content inline
|
// tool_result so message-bearing rows show their content inline
|
||||||
// without an extra click.
|
// without an extra click.
|
||||||
function detailsOpenMd(api, cls, summary, body) {
|
function detailsOpenMd(api, cls, summary, body, icon) {
|
||||||
const d = api.details(cls, summary, '');
|
const d = api.details(cls, summary, '', icon);
|
||||||
d.open = true;
|
d.open = true;
|
||||||
const pre = d.querySelector('pre.tool-body');
|
const pre = d.querySelector('pre.tool-body');
|
||||||
if (pre) {
|
if (pre) {
|
||||||
|
|
@ -1592,13 +1592,13 @@ window.marked = marked;
|
||||||
+ '\n'
|
+ '\n'
|
||||||
+ newLines.map(l => '+ ' + l).join('\n');
|
+ newLines.map(l => '+ ' + l).join('\n');
|
||||||
}
|
}
|
||||||
// Summaries on expandable rows omit the row's directional glyph
|
// The tool icon goes in the shared `.row-glyph` cell (4th arg) so it
|
||||||
// (`→`) — the disclosure marker (`▸/▾`) from CSS sits in the
|
// lines up with flat-row icons; the summary text carries no
|
||||||
// prefix column for every row kind, and the row's cyan colour
|
// directional `→` (the row's cyan colour signals "outbound tool" and
|
||||||
// already signals "outbound tool".
|
// the CSS disclosure caret leads the text).
|
||||||
const summary = toolIcon(name) + ' ' + name + ' ' + path + ' · '
|
const summary = name + ' ' + path + ' · '
|
||||||
+ (minus ? '-' + minus + ' ' : '') + '+' + plus;
|
+ (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
|
// Message-bearing tools render default-open with a markdown body so
|
||||||
// the operator sees the content without an extra click. send / ask
|
// the operator sees the content without an extra click. send / ask
|
||||||
|
|
@ -1608,16 +1608,16 @@ window.marked = marked;
|
||||||
const body = String(input.body || '');
|
const body = String(input.body || '');
|
||||||
const lines = body.split('\n').length;
|
const lines = body.split('\n').length;
|
||||||
return detailsOpenMd(api, 'tool-use',
|
return detailsOpenMd(api, 'tool-use',
|
||||||
toolIcon(name) + ' send → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
'send → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
||||||
body);
|
body, toolIcon(name));
|
||||||
}
|
}
|
||||||
if (name === 'mcp__hyperhive__ask') {
|
if (name === 'mcp__hyperhive__ask') {
|
||||||
const to = input.to || 'operator';
|
const to = input.to || 'operator';
|
||||||
const q = String(input.question || '');
|
const q = String(input.question || '');
|
||||||
const lines = q.split('\n').length;
|
const lines = q.split('\n').length;
|
||||||
const d = detailsOpenMd(api, 'tool-use',
|
const d = detailsOpenMd(api, 'tool-use',
|
||||||
toolIcon(name) + ' ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
'ask → ' + to + (lines > 1 ? ` · ${lines}L` : ''),
|
||||||
q);
|
q, toolIcon(name));
|
||||||
// When the ask targets the operator, mount an inline answer
|
// When the ask targets the operator, mount an inline answer
|
||||||
// slot in the live terminal — see docs/web-ui.md::Per-agent
|
// slot in the live terminal — see docs/web-ui.md::Per-agent
|
||||||
// page (Ask → operator inline-answer binding) for the slot
|
// page (Ask → operator inline-answer binding) for the slot
|
||||||
|
|
@ -1646,8 +1646,8 @@ window.marked = marked;
|
||||||
const a = String(input.answer || '');
|
const a = String(input.answer || '');
|
||||||
const lines = a.split('\n').length;
|
const lines = a.split('\n').length;
|
||||||
return detailsOpenMd(api, 'tool-use',
|
return detailsOpenMd(api, 'tool-use',
|
||||||
toolIcon(name) + ' answer #' + id + (lines > 1 ? ` · ${lines}L` : ''),
|
'answer #' + id + (lines > 1 ? ` · ${lines}L` : ''),
|
||||||
a);
|
a, toolIcon(name));
|
||||||
}
|
}
|
||||||
// Bash task runner — show full command in an expandable pre block so
|
// Bash task runner — show full command in an expandable pre block so
|
||||||
// multi-line scripts are readable. Summary uses the first line so the
|
// multi-line scripts are readable. Summary uses the first line so the
|
||||||
|
|
@ -1655,8 +1655,8 @@ window.marked = marked;
|
||||||
if (name === 'mcp__bash__run') {
|
if (name === 'mcp__bash__run') {
|
||||||
const cmd = String(input.cmd || '');
|
const cmd = String(input.cmd || '');
|
||||||
const firstLine = cmd.split('\n')[0];
|
const firstLine = cmd.split('\n')[0];
|
||||||
const summary = toolIcon(name) + ' run* $ ' + trim(firstLine.trim(), 72);
|
const summary = 'run* $ ' + trim(firstLine.trim(), 72);
|
||||||
return api.details('tool-use', summary, '$ ' + cmd);
|
return api.details('tool-use', summary, '$ ' + cmd, toolIcon(name));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -1803,12 +1803,12 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
else if (c.type === 'thinking') {
|
else if (c.type === 'thinking') {
|
||||||
const txt = (c.thinking || c.text || '').trim();
|
const txt = (c.thinking || c.text || '').trim();
|
||||||
api.row('thinking', txt ? '💭 ' + txt : '💭 thinking …');
|
api.row('thinking', txt || 'thinking …', '💭');
|
||||||
}
|
}
|
||||||
else if (c.type === 'tool_use') {
|
else if (c.type === 'tool_use') {
|
||||||
if (c.id && c.name) toolNameById.set(c.id, c.name);
|
if (c.id && c.name) toolNameById.set(c.id, c.name);
|
||||||
if (!renderRichToolUse(c, api)) {
|
if (!renderRichToolUse(c, api)) {
|
||||||
api.row('tool-use', toolIcon(c.name) + ' ' + fmtToolUse(c));
|
api.row('tool-use', fmtToolUse(c), toolIcon(c.name));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,19 @@
|
||||||
margin: 0.1em 0;
|
margin: 0.1em 0;
|
||||||
}
|
}
|
||||||
.live .row + .row { border-top: 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;
|
/* Row-kind colours. Pages register renderers that emit these classes;
|
||||||
any class no page emits is just dead CSS, which is fine. Turn-framing
|
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 —
|
classes carry their signal entirely on the coloured border-left rule —
|
||||||
|
|
@ -189,11 +202,15 @@
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
|
/* Expandable rows reuse the flat-row prefix metrics (padding-left +
|
||||||
negative text-indent) so the disclosure glyph (`▸ / ▾`) lands in
|
negative text-indent). The summary's icon (when present) sits in the
|
||||||
exactly the same column as flat-row prefix glyphs (`→ ← · ◆ ✓ ✗`).
|
shared `.row-glyph` column — same cell as a flat row's icon — so a
|
||||||
Summary text omits the per-row directional glyph (the row colour
|
`details` summary's `🖥️` lines up under a flat row's `🧠`. The
|
||||||
already carries cyan = outbound tool, muted = inbound result) so
|
disclosure caret (`▸ / ▾`) leads the `.summary-text` (not the icon) via
|
||||||
the prefix column doesn't have to fit two glyphs side-by-side. */
|
`::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 {
|
details.row {
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
@ -203,11 +220,11 @@ details.row > summary {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
details.row > summary::before {
|
details.row > summary > .summary-text::before {
|
||||||
content: '▸ ';
|
content: '▸ ';
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
details.row[open] > summary::before { content: '▾ '; }
|
details.row[open] > summary > .summary-text::before { content: '▾ '; }
|
||||||
details.row > pre.diff-body,
|
details.row > pre.diff-body,
|
||||||
details.row > pre.tool-body {
|
details.row > pre.tool-body {
|
||||||
margin: 0.3em 0 0.4em 0;
|
margin: 0.3em 0 0.4em 0;
|
||||||
|
|
|
||||||
|
|
@ -29,11 +29,14 @@
|
||||||
//
|
//
|
||||||
// Renderers receive (ev, api) where api exposes:
|
// Renderers receive (ev, api) where api exposes:
|
||||||
//
|
//
|
||||||
// api.row(cls, text) → appends a flat <div class="row cls">
|
// api.row(cls, text, icon?) → appends a flat <div class="row cls">;
|
||||||
// api.details(cls, summary, body) → appends <details class="row cls">
|
// optional `icon` lands in a
|
||||||
// with a <pre.tool-body>
|
// fixed-width `.row-glyph` cell
|
||||||
// api.detailsDiff(cls, summary, body) → ditto but body is line-coloured by
|
// api.details(cls, summary, body, icon?) → appends <details class="row cls">
|
||||||
// leading "+ " / "- " prefix
|
// 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
|
// api.placeholder(text) → replaces log content with a single
|
||||||
// muted "(placeholder)" row, cleared
|
// muted "(placeholder)" row, cleared
|
||||||
// on the next real row
|
// on the next real row
|
||||||
|
|
@ -222,24 +225,47 @@ export function create(opts) {
|
||||||
log.appendChild(e);
|
log.appendChild(e);
|
||||||
placeholderEl = 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();
|
clearPlaceholder();
|
||||||
const wasNearBottom = isNearBottom();
|
const wasNearBottom = isNearBottom();
|
||||||
const e = document.createElement('div');
|
const e = document.createElement('div');
|
||||||
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
e.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||||
|
if (icon != null && icon !== '') e.appendChild(glyphSpan(icon));
|
||||||
e.appendChild(linkify(text));
|
e.appendChild(linkify(text));
|
||||||
log.appendChild(e);
|
log.appendChild(e);
|
||||||
afterAppend(wasNearBottom);
|
afterAppend(wasNearBottom);
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
function details(cls, summary, body) {
|
function details(cls, summary, body, icon) {
|
||||||
clearPlaceholder();
|
clearPlaceholder();
|
||||||
const wasNearBottom = isNearBottom();
|
const wasNearBottom = isNearBottom();
|
||||||
const d = document.createElement('details');
|
const d = document.createElement('details');
|
||||||
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||||
const s = document.createElement('summary');
|
d.appendChild(buildSummary(summary, icon));
|
||||||
s.textContent = summary;
|
|
||||||
d.appendChild(s);
|
|
||||||
const pre = document.createElement('pre');
|
const pre = document.createElement('pre');
|
||||||
pre.className = 'tool-body';
|
pre.className = 'tool-body';
|
||||||
pre.appendChild(linkify(body));
|
pre.appendChild(linkify(body));
|
||||||
|
|
@ -248,14 +274,12 @@ export function create(opts) {
|
||||||
afterAppend(wasNearBottom);
|
afterAppend(wasNearBottom);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
function detailsDiff(cls, summary, body) {
|
function detailsDiff(cls, summary, body, icon) {
|
||||||
clearPlaceholder();
|
clearPlaceholder();
|
||||||
const wasNearBottom = isNearBottom();
|
const wasNearBottom = isNearBottom();
|
||||||
const d = document.createElement('details');
|
const d = document.createElement('details');
|
||||||
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : '');
|
||||||
const s = document.createElement('summary');
|
d.appendChild(buildSummary(summary, icon));
|
||||||
s.textContent = summary;
|
|
||||||
d.appendChild(s);
|
|
||||||
const pre = document.createElement('pre');
|
const pre = document.createElement('pre');
|
||||||
pre.className = 'tool-body diff-body';
|
pre.className = 'tool-body diff-body';
|
||||||
for (const line of String(body).split('\n')) {
|
for (const line of String(body).split('\n')) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue