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