<!-- GENERATED by scripts/build-llms.mjs from llms/agent-tools.md — do not edit this file. -->

# `lr-confirm-bar`

- **Import** `import '@aceshooting/lyra-ui/components/lr-confirm-bar.js';` (stable tag alias; registers the tag)
- **Class** `LyraConfirmBar`, also available unregistered from `@aceshooting/lyra-ui/components/agent-tools/confirm-bar/confirm-bar.class.js`
- **Family** `components/agent-tools/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 19 parts, 5 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-confirm-bar`

An inline, non-modal approve/deny block for one proposed action — the in-flow sibling of
`lr-tool-approval-dialog` for confirmations that should sit in the transcript instead of hijacking
focus. Same `lr-approve`/`lr-deny` event shapes as the dialog, and the same
`toolApprovalHeading`/`toolApprovalArgsLabel`/`deny`/`approve` localization keys, so the two always
translate in lockstep. Non-modal by contract: no focus trap, no scroll lock, no Escape/backdrop
semantics, and it never steals focus when it appears in the transcript. "Never steals focus" and
"no Escape semantics" describe the bar's behavior when `autofocus` and `escape-denies` are both
left unset (the default). A host that swaps a focused control out for this bar can opt into either
or both instead of hand-rolling them, as `<lr-memory-panel>` still does internally
(`focusPendingConfirmation`/`onConfirmKeyDown`). DOM and tab order put Deny before Approve. On
activation, focus moves synchronously to the first available of `returnFocusTo` and
`[part="status"]` (an always-rendered, `tabindex="-1"` element) before the Deny/Approve buttons
unmount, so focus never has a gap where it would fall back to `<body>`. Unset, `returnFocusTo`
leaves that handoff landing on `[part="status"]` exactly as it always has.

**Properties:** `toolName: string = ''` (attribute `tool-name`) — drives the default heading through
the existing `toolApprovalHeading`/`toolApprovalGenericTool` dialog keys. `heading: string = ''` —
free-form heading override for non-tool proposals; wins over `toolName`. `args: unknown = undefined`
(attribute: false) — shown read-only inside a collapsed `lr-details` + `lr-json-viewer` when
defined. `decision: 'approved' | 'denied' | null = null` (reflected) — decided state, set by the
component on activation and host-writable (an externally-resolved decision renders identically and
emits no `lr-approve`/`lr-deny` of its own; `lr-decision-settled` still fires, because the status
really did render). `variant: ConfirmBarVariant = 'neutral'` (reflected) — `'neutral' | 'danger'`, a
genuine two-member subset of the library-wide `LyraVariant` vocabulary (spelled as an `Extract` of
it, so the two can never drift): a confirmation is either routine or destructive, and
`brand`/`success`/`warning` have no meaning for a proposal awaiting a yes/no. `compact: boolean = false`
(reflected) — collapses the bar from a stacked `display: block` card into a single tightly-padded
inline row, for a confirmation that has to live inside an existing container: a table cell, a card's
action row, a toolbar. The host becomes `inline-flex`, and the narrow-allocation `@container`
treatment is switched off — a compact bar is _expected_ to be narrow, so stretching the buttons to
fill would be exactly wrong. It is a density knob only: the border, corner radius and background
stay. Retune it through `--lr-confirm-bar-compact-padding`/`-gap`. Everything else is unchanged: the
event shapes, the focus-to-`[part="status"]`-before-unmount contract, and `role="group"` with its
heading label. `frame: LyraFrame = 'card'` (reflected) — `'card' | 'plain'`, imported from the
library's shared container-frame vocabulary and behaving exactly as it does on `lr-agent-run`,
`lr-commit-card`, `lr-result-card`, `lr-task-list`, `lr-terminal` and `lr-thinking-panel`:
`'plain'` removes the border, background, padding and corner radius so a bar nested inside a
container that already draws a border doesn't double it, and wins over `compact` when both are set.
Before 9.0.0 `compact` alone did both jobs; a bar that relied on that now needs
`compact frame="plain"`. `ConfirmBarDecision = ApprovalDecision | null` names the final-state type.
`pending: ApprovalAction | null = null` (reflected) — which action is awaiting host
resolution while an `lr-approve`/`lr-deny` listener has called `preventDefault()` on the
now-cancelable event; the pending button shows `loading`, the other is `disabled`. Set `.decision`
to finalize, or clear `.pending` back to `null` to bounce back to the undecided state.
`waitUntil(promise)` in the event detail is the declarative form of that same state machine and
needs no `preventDefault()`: the bar sets `pending` itself, and the promise's settlement finalizes
`decision` or clears `pending` and returns focus to the control that can retry.
`disabled: boolean = false` (reflected) — disables both Deny and Approve and makes activating either
a no-op, without discarding any in-flight `decision`/`pending` state. Distinct from `pending`:
`pending` marks one specific action as awaiting the host while the other stays interactive;
`disabled` blocks both regardless of `pending`. `autofocus: boolean = false` (reflected) — opt-in
focus-on-mount: moves focus into the bar after its own first render, once this element and (when
present) the Deny `<lr-button>` have both completed it. Named after the native global attribute it
stands in for, since the platform's own `autofocus` algorithm only fires for an element already in
the document when it finishes parsing, never for one a host swaps in afterward — this bar's primary
use. Focuses the Deny control when it's present and actually focusable (not `disabled`, not
hidden), else the always-present `[part="status"]`. `escapeDenies: boolean = false` (attribute
`escape-denies`, reflected) — maps Escape on `[part="base"]` to the same outcome as clicking Deny.
A no-op while `disabled`, already decided, or `pending`, exactly like clicking Deny itself, and
never stops propagation when it was a no-op, so an unrelated enclosing dialog's own Escape handling
still sees the event. Scoped to this element's own `[part="base"]` rather than `document`: this bar
is inline and non-modal, not a member of the shared `activateOverlay()` Escape/stacking contract
real overlays use.
`returnFocusTo: ConfirmBarReturnFocusTarget = null` (attribute: false) — where focus goes once a
decision lands, instead of parking on `[part="status"]`.
`ConfirmBarReturnFocusTarget = HTMLElement | null | (() => HTMLElement | null)`; the thunk form is
called at handoff time, because a host that swaps a focused control out for this bar often
re-creates that control on the way back — and, because every supported host framework re-renders
asynchronously relative to that synchronous handoff, the control frequently does not exist yet at
that first call. When the first call does not yet name a live, focusable element, the same handoff
calls the thunk again once the host has had a real chance to react (its own re-render committed),
and moves focus there if it has since appeared and nothing else has claimed focus in the meantime —
this is what makes the swap-a-trigger-for-this-bar case actually work, rather than only working when
the host happens to re-create its control before the decision lands. A plain element value is
resolved once, synchronously, and never retried: it names something that either already exists or
never will. It applies to every path that reaches a decision, a `pending` decision finalized
externally included. A named target that is missing, detached, `inert`, or otherwise refuses focus
falls back to `[part="status"]` rather than to `<body>` — an `inert` element refuses `focus()`
silently. Left unset, the handoff is byte-identical to the shipped one. The pending state is
deliberately *not* affected: while a decision is awaiting resolution, focus still parks on
`[part="status"]`, because that is not the return journey yet.

**Slots:** default — supplementary body content between the heading and the actions (e.g. a
`lr-diff-view`). `footer` — extra content at the start of the action row.

**Events:** `lr-approve` (`detail: { args, waitUntil }` — `args` is the `args` prop as-is, matching
`lr-tool-approval-dialog`'s own `args` detail; cancelable), `lr-deny` (`detail: { waitUntil }`, the
same resolver and no denial data of its own; cancelable), `lr-decision-settled`
(`detail: { decision }`; non-cancelable).

`waitUntil(promise: Promise<unknown>) => void` is ExtendableEvent-style. Calling it from the
listener holds the bar in its `pending` presentation — `loading` on the activated control,
`disabled` on the other — until the promise settles: a resolution finalizes `decision`, a rejection
restores the undecided state and returns focus to the control that can retry. Several `waitUntil()`
calls, from one listener or from several, are awaited together. Calling it after its own dispatch
has finished does nothing (and warns in dev mode); the promise it receives may settle whenever it
likes. It needs no `preventDefault()`, and the imperative path it replaces — `preventDefault()`,
then writing `pending` and later `decision` by hand — still works unchanged. A listener that
resolves the decision itself synchronously, by writing `decision` or `pending` during the dispatch,
wins outright over both: the bar applies no bookkeeping of its own, `waitUntil()`'s included.

`waitUntil` is this component's alone: `<lr-tool-approval-dialog>` emits the same `lr-approve`/
`lr-deny` names without it, so a listener bound to the shared name rather than to one component
must narrow on `event.target` — see that component's Events section.

`lr-decision-settled` fires after the decided `[part="status"]` has rendered and its live-region
announcement has been made, on every path that reaches a decision — the bar's own, a `waitUntil()`
settlement, and a host writing `.decision` directly. It is the signal to swap the bar out on:
awaiting a single `updateComplete` after your own promise resolves is not enough, because the
promise chain and Lit's update queue interleave. A `decision` present in the initial markup
announces and settles nothing — it never transitioned.

**16.0.0 — breaking detail change.** `lr-deny`'s detail changed from `null` to `{ waitUntil }` and
`lr-approve`'s from `{ args }` to `{ args, waitUntil }`. A listener that compared the whole detail
object (`detail === null`, or a deep-equality check against `{ args }`) must read the fields it uses
instead.

**CSS parts:** `base` (`role="group"`), `heading`/`tool-name`, `body`, `args` (the
details/json-viewer wrapper, only rendered when `args` is defined), `footer`, `deny-button`,
`approve-button` (each an `<lr-button>` host, named identically to the dialog's parts),
`deny-button-base`, `deny-button-label`, `deny-button-start`, `deny-button-end`,
`deny-button-spinner`, `approve-button-base`, `approve-button-label`, `approve-button-start`,
`approve-button-end`, `approve-button-spinner` (re-exported from each button's own
`lr-button` parts via `exportparts`; each `*-button-base` route accepts the same-node `base` and
`button` wrapper aliases), `status` (the decided-state text, always present in the DOM as a focus
landing spot).

**Themeable custom properties:** `--lr-confirm-bar-bg` (default `var(--lr-color-surface)`) is
`[part="base"]`'s RESTING background — the default tier every approval prompt renders at, and the
companion to the `compact` density levers below; `frame="plain"` still drops the fill entirely.
The `compact` density is retunable through two further properties, both
scoped to `[part="base"]` while `compact`: `--lr-confirm-bar-compact-padding` (default
`var(--lr-space-s)`, any padding shorthand — overridden entirely by `frame="plain"`) and
`--lr-confirm-bar-compact-gap` (default `var(--lr-space-s)`, the gap between the row's items). They
are inline `var()` fallbacks at their point of use rather than `:host` declarations, so either can
be set on the element _or on any ancestor_, which is what makes "tighten every compact confirm bar
in this panel" a one-rule change on the panel. The chrome-removing
`--lr-confirm-bar-compact-border`, `--lr-confirm-bar-compact-background` and
`--lr-confirm-bar-compact-radius` properties were removed in 9.0.0 along with `compact`'s chrome
behavior: chrome is now `frame`'s job, so keep the default `frame="card"` (and restyle via
`::part(base)`) instead of re-chroming a chrome-less compact bar.

Two further properties recolor the decided state: `--lr-confirm-bar-approved-color` (default
`var(--lr-color-success)`) and `--lr-confirm-bar-denied-color` (default `var(--lr-color-danger)`) —
`[part="status"]`'s text/icon color under `:host([decision='approved'])` and
`:host([decision='denied'])` respectively. Same inline-`var()`-fallback shape as the compact set.
They exist because `::part(status)[decision]` is invalid CSS, so recoloring just this component's
decided state previously meant re-pointing the library-wide `--lr-color-success`/`-danger` tokens and
repainting everything else that reads them.

**Known gotchas:**

- `[part="status"]` is always rendered and must never be given `display: none`. Deciding moves focus
  to it synchronously, before the Deny/Approve buttons unmount, so hiding it would drop focus to
  `<body>`. The shipped `:empty` rule on it has never matched, and that is load-bearing.
- `[part="deny-button"]`/`[part="approve-button"]` are `<lr-button>` hosts. Deny is
  `variant="neutral" appearance="outlined"`; Approve is `variant="brand"` (`"danger"` under this
  component's own `variant="danger"`) at `lr-button`'s default `appearance="accent"`, so the
  destructive-or-primary action is the loud one and the safe action recedes. `--lr-button-*` theming
  reaches both directly. A
  consumer previously styling `::part(deny-button)`/`::part(approve-button)` for
  padding/border/font/`:hover`/`:focus-visible` must move that CSS onto the re-exported
  `deny-button-base`/`approve-button-base` sub-parts instead — the outer part now resolves to the
  `<lr-button>` host, where those declarations either do nothing or must be re-expressed through
  `lr-button`'s own parts/custom properties.
- An `lr-approve`/`lr-deny` listener can call `preventDefault()` to keep the decision open while
  its own async work is in flight — see `pending` above. If that same listener resolves the
  decision itself synchronously (setting `.decision` or `.pending` directly before returning), that
  wins outright: the component's own built-in `pending` bookkeeping only applies when the listener
  left both untouched, so a listener finalizing out of band is never silently clobbered back into
  the built-in loading/disabled presentation.
- `disabled` blocks both Deny and Approve and makes activating either a no-op — see `disabled`
  above. It is independent of, and composes with, `pending`.
- `autofocus`/`escape-denies` are both opt-in and default to `false`; neither changes any
  existing bar's behavior unless a host sets it. `escape-denies` is intentionally *not* routed
  through the shared overlay Escape manager (`src/internal/overlay-manager.ts`) — this component
  is explicitly non-modal (see the class doc), so binding Escape on its own `[part="base"]` is
  the correct scope, not a shortcut around the shared contract.

```html
<lr-tool-call-chip status="pending"></lr-tool-call-chip>
<lr-confirm-bar tool-name="run_shell"></lr-confirm-bar>
<script type="module">
  const bar = document.querySelector("lr-confirm-bar");
  bar.args = args;
  bar.addEventListener("lr-approve", (e) => run(e.detail.args));
  bar.addEventListener("lr-deny", () => cancel());
</script>
```

An `lr-approve`/`lr-deny` listener that needs to await its own async work before finalizing calls
`preventDefault()` and sets `.decision` (or clears `.pending`) once it resolves:

```ts
bar.addEventListener("lr-approve", (e) => {
  e.preventDefault();
  runApproval(e.detail.args)
    .then(() => {
      bar.decision = "approved";
    })
    .catch(() => {
      bar.pending = null;
    }); // bounce back, retry
});
```

A host that reveals this bar in place of a control it just hid — the `returnFocusTo` motivating
case — does not need to order that swap relative to the line above. A reactive host's own re-render
(replacing this bar with its trigger again) runs on its own update cycle, which lands asynchronously
either way, so `returnFocusTo`'s thunk is written to be called twice: once immediately, in case the
control already exists, and once more after the host has had a chance to react if the first call
found nothing yet:

```ts
bar.returnFocusTo = () => document.querySelector('[data-action="delete"]');
bar.addEventListener("lr-approve", (e) => {
  e.preventDefault();
  runApproval(e.detail.args)
    .then(() => {
      bar.decision = "approved"; // the host's own state clear can happen before or after this
    })
    .catch(() => {
      bar.pending = null;
    });
});
```
