Per mara's screenshot report (agent-terminal icons not aligned in the
first column) — and her explicit follow-up steer on the first version of
this fix: 'dont do it by offsets at all, it should be part of the layout
that they align.'
Root cause traced first, not guessed: .row-glyph relied on inheriting
text-indent: -1.4em from .live .row to paint its glyph in the reserved
prefix slot; details.row > summary separately reset text-indent: 0 for
its own hanging-caret needs, which also zeroed the inherited value for
the icon nested inside it (indent inherits by computed value, not by
rule). Two independently-computed offsets that had to agree by hand,
and silently didn't.
Replaced the whole offset scheme with a real structural fix: every row
is a 2-column CSS grid (icon column, width from one shared
--row-icon-col custom property, then content column), and a details
row grids its own <summary> with the exact same grid-template-columns
value instead of griding itself (so its <pre> body still stacks full
width below, not squeezed into column 2). Icon and content are placed
by explicit grid-column, not auto-placement inference, so an icon-less
row's lone child still lands in the content column. .row-content is a
new wrapper class (Row.tsx, terminal.js's row()/mutableRow()/
placeholder()) giving that content an element the grid can target by
class - a DocumentFragment (what linkify() returns) doesn't persist as
a node once appended, so without an explicit wrapper there was nothing
for the grid to place.
Caught and fixed a second real bug while building this for real (not
just reasoning about the CSS): the first draft's details.row { display:
block } had lower specificity than .live .row's display: grid and never
actually applied, squeezing <summary> into the row's own 1.4em icon
column and wrapping its text one character per line. Needed
.live details.row to out-specify it.
Verified with a headless-chromium render of all 6 row shapes (flat
icon / flat icon-less / flat markdown-body / details icon / details
icon-less / a long-wrapping flat row) - all align and wrap correctly.
Also verified via the earlier Range.getBoundingClientRect() measurement
(glyph paint position, not just the element's own box): diff 0.00 for
both this and the previous fix, but only this one is structural rather
than two numbers that happen to still agree today.
388 lines
13 KiB
CSS
388 lines
13 KiB
CSS
/* Shared terminal pane: a scroll-sticky log of rows + a "↓ N new" pill.
|
|
Pages wrap their stream container in `.terminal-wrap` and give the log
|
|
itself the `.live` class; renderer JS appends `.row` (flat line) or
|
|
`details.row` (collapsible body) elements. Row-kind classes
|
|
(`.turn-start`, `.tool-use`, `.thinking`, etc.) carry the per-event
|
|
colour; pages that don't emit a given kind simply never produce that
|
|
class — the unused rule sits in the bundle harmlessly.
|
|
|
|
`.terminal-wrap` provides the crust-on-black phosphor chrome that
|
|
makes the agent page feel like a terminal. Pages can opt in by
|
|
wrapping a block in this class; or skip it and the rows still render
|
|
with their class colours, just without the frame.
|
|
|
|
No `.term-input` here — composers are a separate concern, owned by
|
|
each page's own CSS. Row taxonomy + layout contract documented in
|
|
`docs/web-ui/shape.md::Shared terminal pane` and
|
|
`docs/web-ui/terminal-rendering.md`. */
|
|
|
|
.terminal-wrap {
|
|
position: relative;
|
|
background: color-mix(in srgb, var(--crust) 78%, transparent);
|
|
-webkit-backdrop-filter: blur(8px) saturate(120%);
|
|
backdrop-filter: blur(8px) saturate(120%);
|
|
border: 1px solid var(--purple-dim);
|
|
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.7);
|
|
border-radius: 4px;
|
|
font-family:
|
|
"JetBrains Mono", "Fira Code", "Cascadia Code", "Source Code Pro", monospace;
|
|
font-size: 0.92em;
|
|
color: var(--fg);
|
|
margin-top: 0.6em;
|
|
}
|
|
.live {
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border: 1px solid var(--purple-dim);
|
|
padding: 0.4em 0.6em;
|
|
overflow-y: auto;
|
|
max-height: 32em;
|
|
font-family: inherit;
|
|
/* Disable browser scroll anchoring — the terminal manages scroll
|
|
position manually (snapToBottom + loadMore compensation). Scroll
|
|
anchoring would double-compensate scrollTop during loadMore()
|
|
prepends, causing an erratic jump. */
|
|
overflow-anchor: none;
|
|
/* Single source of truth for the icon column's width, shared by every
|
|
row kind's own grid below (`.live .row` and `details.row > summary`).
|
|
Structural alignment: both contexts read the *same* custom property
|
|
rather than each hand-computing an offset that has to independently
|
|
agree with the other (mara: "dont do it by offsets at all, it should
|
|
be part of the layout that they align" — see the incident this
|
|
replaced, two paragraphs down). */
|
|
--row-icon-col: 1.4em;
|
|
}
|
|
.live.terminal {
|
|
background: transparent;
|
|
border: 0;
|
|
box-shadow: none;
|
|
border-radius: 0;
|
|
padding: 0.8em 1em 0.4em;
|
|
overflow-y: auto;
|
|
height: min(72vh, 60em);
|
|
max-height: none;
|
|
font-family: inherit;
|
|
font-size: inherit;
|
|
color: inherit;
|
|
}
|
|
.live .row,
|
|
.live details.row {
|
|
animation: row-fade-in 220ms ease-out both;
|
|
}
|
|
.live .row.no-anim,
|
|
.live details.row.no-anim {
|
|
animation: none;
|
|
}
|
|
@keyframes row-fade-in {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(4px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
/* Unified prefix column for every row kind, via CSS grid rather than
|
|
hand-computed offsets (see the incident this replaced, below). A flat
|
|
row is its own 2-column grid (`--row-icon-col` | content); a `details`
|
|
row instead grids its `<summary>` (see further down) so its `<pre>`
|
|
body can stack under the *whole* row, full width, not just the content
|
|
column. `.row-glyph` and the row's actual content are both placed by
|
|
explicit `grid-column` (below), never by auto-placement inference —
|
|
an icon-less row's content still lands in column 2, not wherever the
|
|
grid would otherwise auto-place a lone child.
|
|
|
|
Previously: `padding-left` + a negative `text-indent` pulling the first
|
|
inline box (or bare first character) back into the reserved slot.
|
|
Replaced after a real bug, found the same session: `.row-glyph` needed
|
|
its own copy of that indent to actually paint there (being
|
|
`inline-block`, it's a block container for its own text, so it doesn't
|
|
inherit the *effect* just by inheriting the *value*) — and
|
|
`details.row > summary` separately reset `text-indent: 0` for its own
|
|
hanging-caret needs, which silently zeroed the *inherited* indent for
|
|
the icon nested inside it too, since indent inherits by computed
|
|
value, not by rule. A details-row icon ended up painted 1.4em right of
|
|
a flat row's despite both icons' own boxes already sharing a left edge
|
|
— two independent numbers (`padding-left`/`text-indent` here,
|
|
`margin-left`/`padding-left` on `details.row > summary`) that had to
|
|
agree by hand, and silently didn't. mara: "dont do it by offsets at
|
|
all, it should be part of the layout that they align" — a shared grid
|
|
column, read from one custom property, replaces both. */
|
|
.live .row {
|
|
display: grid;
|
|
grid-template-columns: var(--row-icon-col) 1fr;
|
|
column-gap: 0.5em;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
padding: 0.05em 0;
|
|
line-height: 1.45;
|
|
border-left: 2px solid transparent;
|
|
margin: 0.1em 0;
|
|
}
|
|
.live .row + .row {
|
|
border-top: 0;
|
|
}
|
|
/* Icon column. Every row kind's icon lands in `grid-column: 1` of
|
|
whichever grid is active in that context (this rule for a flat row,
|
|
`details.row > summary`'s own grid further down for a collapsible
|
|
one) — same column definition (`--row-icon-col`), so every icon's
|
|
left edge lines up regardless of the glyph's own rendered width
|
|
(emoji differ; some carry a variation selector). */
|
|
.live .row-glyph {
|
|
grid-column: 1;
|
|
}
|
|
/* Content column — explicit, not left to grid auto-placement. Rows
|
|
built with an icon (see terminal.js `row()` / Row.tsx) wrap their
|
|
actual content in `.row-content` (a markdown body just uses its own
|
|
`.md` class, styled the same way below) specifically so an icon-less
|
|
row's lone remaining child still lands in column 2: without an
|
|
explicit `grid-column`, auto-placement would put a row's *only* grid
|
|
item in column 1 (the first available cell), landing text in the
|
|
narrow icon column instead of the content one. */
|
|
.live .row-content,
|
|
.live .row > .md,
|
|
.live .row > details {
|
|
grid-column: 2;
|
|
min-width: 0;
|
|
}
|
|
/* Row colours, keyed by severity `level` (hive-agent's `term_msg.rs`),
|
|
not by row kind any more — mara's terminal-message redesign dropped
|
|
the server-side `kind` tag (turn-start/tool-use/tool-result/etc) in
|
|
favour of one uniform shape, `{icon, level, summary, body,
|
|
body_format, coalesce_key}`. What used to be a dozen-odd per-kind
|
|
classes (`.turn-start`, `.tool-use`, `.tool-result.error`, `.sys`, …)
|
|
is now four: the four severities every row already carries. Structural
|
|
identity (this is a turn boundary, this is a tool call) is carried by
|
|
the row's icon (`◆`, `🔧`, `✅`, …) and summary text instead of colour —
|
|
see docs/web-ui/terminal-rendering.md. */
|
|
.live .level-debug {
|
|
color: var(--muted);
|
|
}
|
|
.live .level-info {
|
|
color: var(--fg);
|
|
}
|
|
.live .level-warn {
|
|
color: var(--amber);
|
|
border-left-color: var(--amber);
|
|
}
|
|
.live .level-error {
|
|
color: var(--red);
|
|
border-left-color: var(--red);
|
|
}
|
|
/* `badge-pulse` itself is no longer used by any terminal row (the
|
|
turn-start `unread` count it animated is gone — see term_msg.rs's
|
|
module doc), but agent.css's `.state-badge.state-thinking`/
|
|
`.state-compacting` badges still reuse this keyframe via
|
|
`@import "@hive/shared/terminal.css"` — keep the definition here, drop
|
|
only the terminal-specific `.unread-badge` selector that used it. */
|
|
@keyframes badge-pulse {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
text-shadow: 0 0 6px color-mix(in srgb, var(--amber) 55%, transparent);
|
|
}
|
|
50% {
|
|
opacity: 0.7;
|
|
text-shadow: 0 0 14px color-mix(in srgb, var(--amber) 95%, transparent);
|
|
}
|
|
}
|
|
/* "↓ N new" pill: shown when new rows arrive while the operator is
|
|
scrolled up; click to jump to bottom. Positioned by the wrapper's
|
|
`position: relative` (terminal-wrap supplies it; pages that skip the
|
|
wrapper must add their own positioned ancestor). */
|
|
.tail-pill {
|
|
position: absolute;
|
|
right: 1em;
|
|
bottom: 4.2em;
|
|
background: var(--amber);
|
|
color: var(--crust);
|
|
font-family: inherit;
|
|
font-size: 0.8em;
|
|
font-weight: bold;
|
|
letter-spacing: 0.08em;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
padding: 0.35em 0.9em;
|
|
cursor: pointer;
|
|
box-shadow: 0 0 14px -2px color-mix(in srgb, var(--amber) 85%, transparent);
|
|
opacity: 0;
|
|
transform: translateY(6px);
|
|
pointer-events: none;
|
|
transition:
|
|
opacity 160ms ease,
|
|
transform 160ms ease;
|
|
}
|
|
.tail-pill.visible {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
pointer-events: auto;
|
|
}
|
|
.tail-pill:hover {
|
|
filter: brightness(1.1);
|
|
}
|
|
/* "↑ load older" pill: sits inline at the top of the log (not
|
|
absolutely positioned) so it scrolls with the content. Appears
|
|
when `has_more` is true after initial history load. */
|
|
.load-more-pill {
|
|
display: block;
|
|
width: 100%;
|
|
background: transparent;
|
|
color: var(--muted);
|
|
font-family: inherit;
|
|
font-size: 0.8em;
|
|
font-weight: bold;
|
|
letter-spacing: 0.08em;
|
|
border: 0;
|
|
border-bottom: 1px dashed var(--purple-dim);
|
|
padding: 0.4em 1em;
|
|
cursor: pointer;
|
|
text-align: left;
|
|
transition:
|
|
color 120ms ease,
|
|
background 120ms ease;
|
|
}
|
|
.load-more-pill:hover:not(:disabled) {
|
|
color: var(--fg);
|
|
background: var(--border);
|
|
}
|
|
.load-more-pill:disabled {
|
|
cursor: default;
|
|
opacity: 0.6;
|
|
}
|
|
/* Expandable rows don't grid themselves the way a flat row does — a
|
|
`<details>` needs its `<summary>` and `<pre>` body to stack normally
|
|
(full row width each), not sit side by side in the row's own 2
|
|
columns. `.live details.row` (not just `details.row`) overrides
|
|
`.live .row`'s `display: grid` back to block for the `<details>`
|
|
element itself — needs the extra `.live` to out-specify it (two
|
|
classes beats one class + one type selector), or the override never
|
|
applies and `<summary>`/`<pre>` get squeezed into the *row's* 2-column
|
|
grid instead of stacking (caught rendering this for real: a
|
|
`<summary>` auto-placed into the 1.4em icon column wraps its text one
|
|
character per line). The icon+text split happens one level down, on
|
|
`<summary>` alone, which grids itself with the exact same
|
|
`grid-template-columns: var(--row-icon-col) 1fr` as `.live .row` —
|
|
same column, same custom property, so a details summary's icon and a
|
|
flat row's icon share a left edge *because they're the same declared
|
|
column*, not because two independently-computed offsets happen to
|
|
agree. The disclosure caret (`▸ / ▾`) leads `.summary-text` (not the
|
|
icon) via `::before`. The summary text carries no `→ / ←`; the row
|
|
colour (cyan = outbound tool, muted = inbound result) carries the
|
|
direction. */
|
|
.live details.row {
|
|
display: block;
|
|
white-space: normal;
|
|
}
|
|
details.row > summary {
|
|
display: grid;
|
|
grid-template-columns: var(--row-icon-col) 1fr;
|
|
column-gap: 0.5em;
|
|
align-items: baseline;
|
|
cursor: pointer;
|
|
list-style: none;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
/* `.row-glyph`'s own `grid-column: 1` (set once, above) already applies
|
|
here too — nothing details-specific to repeat. `.summary-text` needs
|
|
its own explicit `grid-column: 2` for the same reason `.row-content`
|
|
does on a flat row: an icon-less summary's only child must not fall
|
|
back to column 1 via auto-placement. */
|
|
details.row > summary > .summary-text {
|
|
grid-column: 2;
|
|
min-width: 0;
|
|
}
|
|
details.row > summary > .summary-text::before {
|
|
content: "▸ ";
|
|
color: inherit;
|
|
}
|
|
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;
|
|
padding: 0.4em 0.6em;
|
|
background: rgba(255, 255, 255, 0.02);
|
|
border-left: 2px solid var(--purple-dim);
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
max-height: 22em;
|
|
overflow-y: auto;
|
|
}
|
|
details.row > pre.tool-body {
|
|
color: var(--fg);
|
|
}
|
|
details.row > pre.diff-body .diff-add {
|
|
color: var(--green);
|
|
}
|
|
details.row > pre.diff-body .diff-del {
|
|
color: var(--red);
|
|
}
|
|
details.row > pre.diff-body .diff-ctx {
|
|
color: var(--fg);
|
|
}
|
|
/* Markdown body inside a row (assistant text, send/recv message
|
|
bodies). Inline elements get muted accents. */
|
|
.live .row .md p {
|
|
margin: 0.2em 0;
|
|
}
|
|
.live .row .md p:first-child {
|
|
margin-top: 0;
|
|
}
|
|
.live .row .md p:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
.live .row .md code {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
padding: 0.05em 0.3em;
|
|
border-radius: 3px;
|
|
font-size: 0.95em;
|
|
}
|
|
.live .row .md pre {
|
|
margin: 0.3em 0;
|
|
padding: 0.4em 0.6em;
|
|
background: rgba(255, 255, 255, 0.04);
|
|
border-left: 2px solid var(--purple-dim);
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
.live .row .md pre code {
|
|
background: transparent;
|
|
padding: 0;
|
|
border-radius: 0;
|
|
}
|
|
.live .row .md a {
|
|
color: var(--cyan);
|
|
text-decoration: underline;
|
|
}
|
|
/* Auto-linkified bare URLs in plain rows + tool-body blocks. */
|
|
.live .row a {
|
|
color: var(--cyan);
|
|
text-decoration: underline;
|
|
}
|
|
.live .row a:hover {
|
|
color: var(--fg);
|
|
}
|
|
.live .row .md strong {
|
|
color: inherit;
|
|
font-weight: bold;
|
|
}
|
|
.live .row .md em {
|
|
color: inherit;
|
|
font-style: italic;
|
|
}
|
|
.live .row .md ul,
|
|
.live .row .md ol {
|
|
margin: 0.2em 0 0.2em 1.4em;
|
|
padding: 0;
|
|
}
|
|
.live .row .md li {
|
|
margin: 0.05em 0;
|
|
}
|
|
.live .row .md blockquote {
|
|
margin: 0.2em 0;
|
|
padding-left: 0.6em;
|
|
border-left: 2px solid var(--purple-dim);
|
|
color: var(--muted);
|
|
}
|