ParameterDiff
What an agent's action will change, before anyone approves it — old and new values side by side or merged inline, marked by glyph and word as well as colour, and read out as one sentence per row.
pnpm add @tessinaui/uiWhen to use
- Before an approval. An agent is about to write parameters or a record, and the reader should see exactly what changes before they say yes.
- A "review your changes" step. A settings form or an import can end on the list of values that will be written, so nothing is a surprise.
- Values the reader can compare in a glance. Short strings, numbers, booleans and small objects fit a row; a long value is cut with a Show more toggle rather than pushing the rest away.
- Inputs that arrive over time. Set
streamingand the reader watches changes appear without the rows moving. - A change that must not rely on colour. Every row carries a glyph and a spoken word as well as its tint, so added, removed and modified read the same in greyscale and to a screen reader.
When not to use
- The call itself. Use ToolCall, which carries the tool's name, status, arguments and the Approve / Deny decision. Put a ParameterDiff inside it when the arguments are a change to existing values.
- How risky the change is. ToolCall's
riskprop carries the tier and itseffectsslot takes a ParameterDiff as what approving will do; this component shows what changes, not how dangerous it is. - Source code or long documents. A line diff with context and hunks is a code-review component. This one compares values, word by word.
- Collecting new values. Use Elicitation to ask for input; a diff only shows what is already decided.
- A change nobody needs to check. If the reader would approve it without looking, a confirmation sentence is enough and a table of rows is noise.
Installation
pnpm add @tessinaui/uiUsage
import { ParameterDiff } from "@tessinaui/ui/parameter-diff";
<ParameterDiff
changes={[
{ name: "limit", before: 10, after: 25 },
{ name: "sort", before: "asc", after: "desc" },
{ name: "owner", after: "platform" },
]}
/>Each change is a name with an optional before and after, and its kind
follows from which are there: no before is added, no after is
removed, and both are modified — or unchanged when they render to
the same text. Values can be anything: a string renders as itself, a number or
boolean as its text, and an object or array as JSON.
The diff is a snapshot. Pass the changes as they stand on every render; it keeps no state of its own except what the reader has expanded.
Examples
Default
Modes
Side by side (the default) puts the old value and the new one in two columns, and stacks them below 768px. Inline merges them into one value, striking the removed words and underlining the added ones.
Prose diff
TextDiff diffs a passage word by word as running text — an edit an agent
proposes to a paragraph, two versions of a prompt. Removed words are a struck
del, added words an underlined ins (unless underline is off), each opened by a hidden "Removed:" or
"Added:". A change never spans a line break unless glyphs is set, and a passage with no words in
common reads as one removal and one addition.
The root keeps the passage's own line breaks and collapses a space at a wrap, so
a marked run never carries a stray space at the end of a line. Added words are
underlined by default; set underline={false} only on a surface where another
cue already marks additions, such as a tinted row. Set glyphs for a passage
that changes by the line: each mark opens with + or − and a changed run
stays whole across its line breaks.
Pass tokenize so a unit of your own, such as {{variable}}, is compared
whole, and renderText to draw each run's text — here, variables as chips.
Streaming
Long values and many rows
A value past 160 characters is cut with a Show more toggle, and rows past
maxVisible wait behind a Show N more changes toggle.
Unchanged rows
On color
RTL
Loading
Streaming
The diff follows the streaming-stable contract every AI-kit component shares (CONVENTIONS §7c):
- Any prefix renders. No props, an empty list, a name cut mid-word — each renders, none throws. A change with neither value yet waits until one arrives, and a change that is not an object is skipped.
- Arrived rows never move. Rows are keyed by position, because a stream only appends and a name can still be arriving. A modified row reads as removed until its new value arrives, then settles in place.
- Rows never disappear mid-stream. While
streamingis set, unchanged rows are shown: a value can equal the old one for a moment ("1" on its way to "12"), and hiding the row would move every row below it. - Streaming is never announced. While
streamingis set the diff isaria-busywitharia-live="off". The card around it announces the transitions; the diff has no live region of its own.
API Reference
ParameterDiff
| Prop | Type | Default | Description |
|---|---|---|---|
changes | ParameterChange[] | [] | { name, before?, after? } per row, in order |
mode | "side-by-side" | "inline" | "side-by-side" | Two columns (stacked below 768px), or one merged value per row |
showUnchanged | boolean | false | Also render rows whose value did not change. While streaming, they always render |
maxVisible | number | 10 | Rows shown before a "Show N more changes" toggle |
streaming | boolean | false | Marks the diff aria-busy and silent while changes arrive |
label | string | "Changes" | The list's accessible name |
tone | "default" | "on-color" | inherited | Inherited from a wrapping <Surface>, else "default" |
dir | "ltr" | "rtl" | — | Text direction; "rtl" also picks the Arabic strings |
strings | Partial<ParameterDiffStrings> | — | Per-entry string overrides |
Everything else lands on the root.
Also exported: PARAMETER_DIFF_STRINGS (the English and Arabic tables),
PARAMETER_DIFF_VALUE_PREVIEW_CHARS (160), PARAMETER_DIFF_MAX_ROWS (200),
and diffWords(before, after, { tokenize }), the word diff the inline mode and TextDiff use.
TextDiff
| Prop | Type | Default | Description |
|---|---|---|---|
before | string | — | The passage as it is |
after | string | — | The passage after the change |
tokenize | (text: string) => string[] | word segments | Tokens to compare; they must join back to the text exactly |
renderText | (text, kind) => ReactNode | the text | Renders each run's text |
underline | boolean | true | Underline added words. With the strike on removed words it keeps a change from resting on colour alone; turn it off only where another cue already marks additions |
glyphs | boolean | false | Open each mark with a + or − and keep a changed run whole, line breaks included, so an added line reads as one block. For passages that change by the line, like a prompt |
tone | "default" | "on-color" | inherited | Inherited from a wrapping <Surface> |
dir | "ltr" | "rtl" | — | Text direction; "rtl" also picks the Arabic strings |
strings | Partial<TextDiffStrings> | — | The spoken removed / added words; an empty string skips one |
Everything else lands on the root span. Past the word-diff caps the whole old
passage reads removed and the whole new one added.
ParameterDiffSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
rows | number | 3 | How many rows the placeholder stands in for |
mode | "side-by-side" | "inline" | "side-by-side" | Match the diff it replaces |
label | string | — | What AT announces while the placeholder is up. Silent without it |
dir | "ltr" | "rtl" | — | Text direction |
DOM contract
| Attribute | Where | Values |
|---|---|---|
data-slot | every part | parameter-diff, parameter-diff-list, parameter-diff-row, parameter-diff-marker, parameter-diff-name, parameter-diff-value, parameter-diff-before, parameter-diff-after, parameter-diff-empty-side, parameter-diff-run, parameter-diff-reveal, parameter-diff-show-all, parameter-diff-omitted, parameter-diff-no-changes, parameter-diff-skeleton, text-diff, text-diff-mark |
data-kind | each row and each run | row: added · removed · modified · unchanged; run: same · removed · added |
data-mode | the root | side-by-side · inline |
data-tone | the root | default · on-color |
Accessibility
There is no APG pattern for a diff. The diff is a named list, one row per change, and the contract is:
- Each row reads as one sentence. The DOM order is the reading order — name, kind, old value, new value — so a screen reader walking the list hears "limit, Modified, was 10, now 25", "url, Added, now https://x.dev" and "timeout, Removed, was 30". "Was" and "now" are visually hidden text, so the sentence reads the same in either mode.
- Stacking never reorders. Below 768px the two columns stack; that moves where the values are drawn, never the order they are read in.
- Inline runs are read as a sentence too. The merged word runs would read "The quickslow fox" in DOM order, so they are hidden from assistive technology and the row speaks "was The quick fox, now The slow fox" instead.
- A change is never carried by colour alone. Each kind has a glyph (plus, minus, pencil) and a spoken word; removed text is struck through, and added words inside a modified value are underlined.
- Every changed row is reachable by keyboard. Nothing scrolls sideways,
long values wrap, and everything hidden sits behind a real button — the
"Show more" and "Show N more changes" toggles, each with
aria-expanded. Rows are content, not tab stops: Tab moves from one toggle to the next. - Focus never drops. The row toggle stays in place when it expands and flips to "Show fewer changes", so focus stays on it.
- TextDiff marks are real edits. A passage has no parallel sentence to
read, so its removed and added words are
delandinselements, each opened by a hidden "Removed:" or "Added:" for assistive technology that does not announce them. Override either word withstrings; an empty string skips it. - Reduced motion: nothing animates.
AT matrix: not yet screen-reader tested, hence beta.
Notes
- Unchanged rows are hidden by default. A review screen is about what
changes; pass
showUnchangedto show the rest for context. - Words, not lines. The inline mode and
TextDiffcompare word by word, so an edit in the middle of a long prompt marks only the words that changed. A change never spans a line break. Past 2,000 characters or 400 words a side, and while a value is cut, the whole value is marked as replaced instead. - There is a hard cap. No more than 200 rows ever render, even after "Show all"; past it a line says how many were left out.