Skip to content

feat(plugins): offer one line of composer suggestion from a plugin - #1785

Merged
Tioit-Wang merged 3 commits into
mainfrom
feat/plugin-composer-suggestion
Oct 21, 2026
Merged

Tioit-Wang merged 3 commits into
mainfrom
feat/plugin-composer-suggestion

Conversation

@Tioit-Wang

Copy link
Copy Markdown
Collaborator

Adds a plugin-side channel for one short suggestion in the Composer, so a
plugin can propose the user's next message where the user is already looking,
without a toast, a notification, a bubble, or a panel.

What a plugin gets

pi.composer.suggest({
  sessionId,     // the session from session:turnEnded
  turnId?,       // the turn the text answers; an overtaken turn is refused
  text,          // one line; whitespace folded, cut at 200 chars
  ttlMs?,        // default 120000, clamped to 1000..600000
}): Promise<{ shown: true } | {
  shown: false;
  reason: "session-not-visible" | "draft-not-empty" | "stale-turn" | "replaced" | "unsupported";
}>

pi.composer.clearSuggestion({ sessionId? }): Promise<{ ok: true }>

Permission: composer.suggest, risk medium (confirm at install). It can
put text in front of a user who may accept it with one key, but it cannot read
the draft, cannot send, and cannot outlive an empty draft — the same trust shape
as composer.transform.

A place that will not show the line is not an error: the call resolves
{ shown: false, reason } so a plugin can log the miss. Only malformed input
throws (INVALID_PARAMS), and a missing grant throws PERMISSION_DENIED.

How the decision is split

  • Main refuses what it already knows: a sessionId that is not the session
    on screen, and a turnId that onTurnStarted has since overtaken. It then
    relays the offer and waits (2 s) for the renderer's answer.
  • Renderer refuses the rest — no composer, a blocked composer, a draft with
    text or an attachment — and answers over pluginComposerSuggestionResult, so
    the plugin learns the real outcome.

Main never writes renderer state; the offer is a message, exactly like
pi.ui.revealView.

Lifecycle

Shown only while: the named session is on screen, the draft is empty (no text,
no attachment), the named turn is still the session's newest, and a composer
takes input. One suggestion per session; a newer offer replaces the older one.

Cleared by: any draft change (typing, pasting, an attachment,
composer.insertText, composer.replaceDraft), a send, a session switch, a new
turn starting, the plugin unloading/being disabled, clearSuggestion, a
renderer reload, a window close, and the suggestion's own TTL.

Keys: Tab accepts (draft = text, caret at end, input focused, staged
attachment kept), Esc dismisses, and the @ / / completion list keeps Tab
and the first Escape.

Visual: the existing .composer-placeholder layer, brighter (62% primary ink),
one line with an ellipsis, plus a localized Tab / Esc hint and one polite
sr-only announcement. aria-placeholder is unchanged.

Compatibility

Additive: new permission, new SDK namespace, two new IPC channels, one i18n key.
A plugin that does not declare composer.suggest behaves exactly as before. No
manifest schema version, host-core schema, or persisted-format change.

Verification

  • apps/desktop/test/composer-suggestion-state.test.mjs — renderer state
    machine (empty draft, session match, attachments, accept/dismiss, typing,
    session switch, TTL expiry, host clears, key arbitration with completions).
  • apps/desktop/test/composer-suggestion-relay.test.mjs — main relay (session
    visibility, stale turn, replacement, timeout, turn start, plugin stop, quit).
  • apps/desktop/test/plugin-composer-suggestion.test.mjs — real plugin process
    calling pi.composer.suggest / clearSuggestion: text shaping, TTL clamp,
    scope refusal, PERMISSION_DENIED without a grant, INVALID_PARAMS.
  • apps/desktop typecheck; packages/{shared,plugin-sdk,i18n} builds and
    vitest suites; plugin-runtime-risk (permission matrix parity),
    main-wiring-contract, composer/plugin suites; biome lint; style-token guard.

See docs/adr/0332-plugin-composer-suggestion.md.

v_wyxgwang added 2 commits October 20, 2026 18:02
A plugin that reacts to a finished turn often knows what the user plausibly
types next, but every surface it has today interrupts: a toast, a notification,
or a rewrite of a draft the user already wrote. Add `pi.composer.suggest` and
`pi.composer.clearSuggestion`, gated by the new `composer.suggest` permission,
which put one dismissible line into the placeholder layer of the Composer for
the session on screen.

The decision is split along the ownership the host already has. The main process
refuses a session that is not the one on screen and a turn that a newer turn
overtook, because it owns both facts; the renderer refuses a draft that is not
empty, because only it owns the draft, and answers the waiting caller. A plugin
therefore learns `shown: false` with a reason instead of guessing, which is what
lets it stop spending completions on a user who is not looking. Main never
writes renderer state: it sends a message, like `pi.ui.revealView` does.

Nothing a plugin offers becomes draft text on its own. `Tab` writes the line
into the draft with the caret at the end, `Esc` drops it, and typing, sending, a
session switch, a new turn, a plugin stop, a reload or a window close all end
it; the `@` / `/` completion list keeps `Tab` and the first `Escape`. See ADR
0332.
The suggestion bridge kept the composer handle inside its live-draft value,
but it already reaches the newest handle through its own registry. Drop the
duplicate so the value describes only what the decision needs: the session, the
text and how many references the draft holds.
React requires a third snapshot when a component is rendered to a string, and
the app-shell snapshot fixtures render the Composer that way. Without it React
threw "Missing getServerSnapshot" and the three shell-render tests failed on CI
even though the browser path was fine. A suggestion is a live-window fact, so
the server snapshot is simply "none".
@Tioit-Wang
Tioit-Wang merged commit a6f4ba1 into main Oct 21, 2026
7 checks passed
@Tioit-Wang
Tioit-Wang deleted the feat/plugin-composer-suggestion branch October 21, 2026 01:43

This branch was previously deployed

1 inactive deployment
Preview — 7774e30b Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant