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
|
||||
`<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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue