facebook / facebook/astryx

[RFC] ChatComposerInput: a seam for inline completion (ghost text)

Open
#4,822 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
TypeScript
Stars
13.1k
Forks
1.1k
Avg merge
1d 14h
Merged PRs (30d)
669

Description

## Problem Statement

Users typing into a chat composer often want to reuse something they have written before, or accept a suggested continuation, without leaving the keyboard. The interaction is familiar: the rest of the candidate text appears dim after the caret, Tab accepts it, Escape dismisses it, anything else ignores it.

`ChatComposerInput` has no seam for this today, so a consumer has to draw the offered text itself. There is nowhere to draw it except *beside* the editor — and that is the gap, because the offer has to agree with the editor about wrapping, growth, scrolling, the caret, the composition state, and whether an open trigger menu already owns Tab. All six are facts the editor holds and a sibling can only infer.

The failure is not cosmetic. We built the outside version first and measured it: a one-row field offering a 116-character completion showed **57** characters — the rest clipped below the field's current height — and Tab inserted all **116**. Fifty-nine characters entered the draft having never been on screen. Any implementation outside the editor has this shape, because the preview's box is sized by the draft and the insertion is not.

The accessibility half is the same problem. The offer is decoration to a screen reader, so the sibling marks it `aria-hidden` and has no reliable moment at which to announce it — the consumer cannot tell when the offer is *actually* on screen, only when it asked for one. The result is that Tab silently changes meaning: a screen-reader user presses it expecting focus navigation and modifies the draft instead.

## Evidence of Demand

- **Editors**: VS Code / Monaco `InlineCompletionsProvider`, JetBrains full-line completion, Xcode predictive completion — dim text after the cursor, Tab to accept, is the settled convention.
- **Shells**: `fish` autosuggestions and `zsh-autosuggestions` are the same interaction on a single-line input, and are where most users first meet it.
- **Agent CLIs**: `pi`'s TUI editor added a ghost-text API for exactly this ([earendil-works/pi#2355](https://github.com/earendil-works/pi/issues/2355), [badlogic/pi-mono#2165](https://github.com/badlogic/pi-mono/pull/2165)) — an async provider, dim text after the cursor, Tab accepts, suppressed while the autocomplete popup is up. Independent of us, it landed on the same ownership split this RFC argues for.
- **Us**: [maka-agent#1874](https://github.com/maka-agent/maka-agent/pull/1874) completes a draft from the user's own prompt history. It is currently shipping against a local patch of `ChatComposerInput` rather than a parallel editor, which is why this RFC exists.

The demand is not specific to one source of text. History retrieval, a model-generated continuation, and a canned-phrase completion are different decisions with different contracts — synchronous vs cancellable, free vs metered — but they all want the same *visual and keyboard* seam. That is the part worth having in the design system.

## Why Existing Components Don't Cover This

We tried the composition first. `ChatComposerInput` renders the editable, so an offer can only be an absolutely positioned sibling inside the composer, mirroring the draft in `transparent` to push the offered text onto the caret. To do that it has to:

- read the editable out of the component's DOM (`[contenteditable="true"]`, not a public seam);
- copy the editable's computed box and typography onto the mirror — the editable carries its own padding and a line-height the wrapper does not inherit, so restating them in product CSS silently drifts;
- infer trigger-menu state from `aria-expanded` to know whether Tab is already taken;
- probe the selection to decide whether the caret is at the end;
- write an accepted offer back through `document.execCommand`.

That is roughly 200 lines of a second, worse editor beside the real one — and it still cannot fix the clipping above, because the mirror's height is the editable's height and the offer is what overflows it.

`ChatComposerToken` is not a fit either: tokens are part of the value and survive serialization, whereas an offer must be absent from the value until accepted. The closest existing machinery is the ephemeral `data-astryx-dictation-interim` span, which is the right *model* — transient content living in the editor's real flow — but is specific to dictation and is not excluded from `serialize`.

## Rough Approaches Considered

**Option A — a prop on the input (what we patched locally, and what we would send as a PR).**

```tsx

```

The consumer decides *what* to offer. The input decides whether it can be shown, and owns everything downstream of that:

- rendered as a `contenteditable=false` span appended in the editable's real flow, so it wraps, grows and scrolls with the draft — nothing can be committed unseen;
- skipped by `serialize`, so it never reaches the value;
- shown only while the editable is focused, the selection is collapsed at the end of the content, and no trigger menu is open;
- Tab accepts (after the trigger menu has had its chance), Escape dismisses until a different offer arrives;
- accepting is one `insertTextAtCursor` + the usual change emission, so it is one ordinary insertion for undo and for the controlled round trip.

`inlineCompletionLabel` exists because the announcement needs an instruction and "press Tab to accept" is product copy a design system should not invent; without it the completion text is announced alone.

**Option B — a hook** (`useInlineCompletion`) returning props to spread. Rejected: the state that decides showability lives inside the input, so the hook would need the same private DOM access the sibling did.

**Option C — generalize the dictation span** into a documented "ephemeral content" primitive, with inline completion as one caller. More general, and probably the better long-term shape if you already want dictation and completion to share machinery — but it is a larger API surface than the problem needs on its own.

## Accessibility Considerations

Maps to the editing-with-suggestion pattern rather than to combobox: the offer is not a list of options and does not own arrow keys.

- **Keyboard**: Tab accepts, Escape dismisses; both only while an offer is on screen, so an editor with no offer keeps Tab's ordinary focus move and Escape's ordinary meaning. Tab yields to an open trigger menu, which must keep first claim on it. Modified Tab (Shift/Alt/Ctrl/Cmd) is never an acceptance.
- **Announcement**: a polite live region carrying the offered text plus the consumer-supplied instruction, emitted only when an offer is actually rendered — the input is the only thing that knows that, which is the a11y argument for putting this inside it. Cleared on dismissal and on acceptance.
- **The visible span** is `aria-hidden`, `contenteditable=false`, `pointer-events: none` and `user-select: none`, so it is neither reachable by caret nor announced twice.
- **Who benefits**: keyboard-only users get a full recall without arrow-key paging; screen-reader users get told that Tab has a second meaning right now, which today they would only discover by pressing it.

## Performance Considerations

One span, added and removed wholesale on change. No measurement, no observers: the offer participates in the editor's own layout rather than tracking it.

---

Happy to send Option A as a PR — it is written and running behind a patch, so it is a matter of porting it to `src/` with stories and tests rather than starting from scratch. Related: #3635 (Chat maturation tracker), #4655 (inline tokens and external value updates, which the same `serialize` path touches).

Contributor guide

Open the contributing guide

Research direction

Start with ChatComposerInput in src/ and inspect the existing serialize path and ephemeral data-astryx-dictation-interim span. Port the described inline-completion behavior, then add the mentioned stories and tests covering rendering, keyboard handling, serialization, and accessibility announcements.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
accessibility, frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
52/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.