From 1c47bd43338bc9e5316ce1a5045e8889a7bd74cc Mon Sep 17 00:00:00 2001 From: iris Date: Mon, 22 Jun 2026 00:38:00 +0200 Subject: [PATCH] agent-ui: real fixed-width icon column for terminal rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/terminal-rendering.md | 35 ++++++++++----- frontend/packages/agent/src/app.js | 36 ++++++++-------- frontend/packages/shared/src/terminal.css | 31 +++++++++++--- frontend/packages/shared/src/terminal.js | 52 +++++++++++++++++------ 4 files changed, 105 insertions(+), 49 deletions(-) diff --git a/docs/terminal-rendering.md b/docs/terminal-rendering.md index f3b14111..8bed16a3 100644 --- a/docs/terminal-rendering.md +++ b/docs/terminal-rendering.md @@ -13,17 +13,32 @@ event kind today. Source of truth lives in Every row โ€” flat `
` and expandable `
` 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. -`
` 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. + +`
` 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 `
`) get `text-indent: 0` so their content diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index 9fa3baed..ee8e947f 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -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)); } } } diff --git a/frontend/packages/shared/src/terminal.css b/frontend/packages/shared/src/terminal.css index 37413827..4beb8a1c 100644 --- a/frontend/packages/shared/src/terminal.css +++ b/frontend/packages/shared/src/terminal.css @@ -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; diff --git a/frontend/packages/shared/src/terminal.js b/frontend/packages/shared/src/terminal.js index aa13d612..6d5f353d 100644 --- a/frontend/packages/shared/src/terminal.js +++ b/frontend/packages/shared/src/terminal.js @@ -29,11 +29,14 @@ // // Renderers receive (ev, api) where api exposes: // -// api.row(cls, text) โ†’ appends a flat
-// api.details(cls, summary, body) โ†’ appends
-// with a -// api.detailsDiff(cls, summary, body) โ†’ ditto but body is line-coloured by -// leading "+ " / "- " prefix +// api.row(cls, text, icon?) โ†’ appends a flat
; +// optional `icon` lands in a +// fixed-width `.row-glyph` cell +// api.details(cls, summary, body, icon?) โ†’ appends
+// with a ; `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 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')) {