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