feat(plugins): let a plugin put the cursor in any agent's composer

A pill could insert text but left the cursor elsewhere, so the user had
to click the composer before typing. Plugins can address any agent's
composer, and an author cannot know where the user is, so focus() owns
revealing the agent as well as focusing it.

A running agent's composer holds one pending request and navigates to
the agent, completing when an input that can take the keyboard attaches.
Drafts and the New workspace composer have nothing to navigate to and
must not pull the user back, so they reject when off screen.
This commit is contained in:
Mohamed Boudra
2026-11-09 15:39:46 +01:00
parent 0fa7f840b4
commit b10647cae3
19 changed files with 664 additions and 13 deletions
+57 -2
View File
@@ -1953,6 +1953,7 @@ export default function contribute(client: PluginClientContext) {
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `context` | `"agent"` for a running agent, `"draft"` for a new agent in a workspace, `"new-workspace"` for the New workspace screen. |
| `prompt`, `setPrompt(text)`, `insertText(text)` | The prompt. `insertText` replaces the selection or inserts at the cursor. |
| `focus(options?)` | Puts the cursor in this composer, ready to type, and resolves when it is there. `selection: { start, end }` places the cursor or selects text, as offsets into `prompt` clamped to its length; omit it to keep the cursor where it is. See [focus a composer](#focus-a-composer). |
| `attachments`, `addAttachment`, `removeAttachment` | Items from [your own attachment sources](#add-a-composer-attachment-source). Attachments from other plugins and Paseo's own are not listed. |
| `agent.provider`, `model`, `mode`, `thinking` | What the agent runs with, or what the draft will create. |
| `agent.options` | The provider options in effect, defaults included. In a draft: the options the agent will be created with, including your changes. |
@@ -1992,11 +1993,39 @@ The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plug
registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`, plus an
Explorer panel and a Command Center item that reach a composer by reference.
### Focus a composer
`insertText` and `setPrompt` do not move the cursor. Call `focus()` after them so the user can keep
typing:
```tsx
composer.insertText("- [ ] Tests\n");
await composer.focus();
```
`await composer.focus({ selection: { start: 0, end: composer.prompt.length } })` selects the prompt.
You do not need to know where the user is. On a running agent's composer, Paseo navigates to the
agent's tab or workspace when it is not on screen, closes a phone's agent list or Explorer panel
that covers it, and moves focus to the agent's pane, then focuses the input. When the input is
already on screen and focused in its pane, `focus()` only puts the cursor there. A phone opens its
keyboard.
`focus()` rejects when the composer cannot take focus: the agent was archived, its composer did not
come on screen within 15 seconds, or the input refused focus. A rejected promise returned from a
button's `onPress` or a Command Center item's `onSelect` shows the user an error toast.
Drafts and the New workspace composer are only reachable from an item inside them, so `focus()` does
not navigate to them. It works while the composer is on screen, and rejects with "The composer is
not on screen." when the user has already moved on. A locked composer accepts focus.
### Reach a composer by reference
A running agent has a composer whether or not its tab is open. `useComposers(selector)` in any plugin
component, and `composers(selector)` on every command context, return the same `PluginComposer` that
`useComposer()` gives an item, for an agent you name or for the focused one:
component, `composers(selector)` on every command context, and `client.composers(selector)` on the
client context return the same `PluginComposer` that `useComposer()` gives an item, for an agent you
name or for the focused one. Use `client.composers` in a callback that receives no context, such as a
[pill's `onPress`](#composer-pills):
```tsx
import { useComposers, type PluginClientContext } from "@getpaseo/plugin/client";
@@ -2054,6 +2083,7 @@ you reach from outside:
rejects with the provider's message. `model` and `thinking` read what the model picker
shows at that moment: until the provider's catalog loads that is the id the agent reports or was
created with, which may be an alias, and once it loads the catalog's own id.
- `focus()` reveals the composer first, as [described above](#focus-a-composer).
- Drafts and the New workspace screen are not addressable by reference. Only an item inside one
reaches its composer, through `useComposer()`.
@@ -2158,6 +2188,31 @@ const pill = client.addComposerPill({
});
```
A pill's `onPress` receives no arguments. Reach its agent's composer with
[`client.composers`](#reach-a-composer-by-reference):
```tsx
const pill = client.addComposerPill({
id: "hello",
workspaceId,
agentId,
button: {
title: "Add HELLO to the composer",
icon: "Hand",
label: "Hello",
behavior: {
kind: "action",
async onPress() {
for (const composer of client.composers({ agentId })) {
composer.insertText("HELLO");
await composer.focus();
}
},
},
},
});
```
For pills that follow the agent directory, use an explicit [owned list subscription](/docs/sdk/events#follow-one-agents-status).
The [local plugin example](https://github.com/getpaseo/paseo/blob/main/plugin-examples/local-plugin/client/main.tsx)
replaces registrations on each snapshot and aborts the observation during entry cleanup, including pending bootstrap.