paseo-prompt-kit 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/client/actions/enabled.ts +30 -0
- package/client/commands/rewrite-command.ts +54 -0
- package/client/composer-bridge/adapter.ts +15 -0
- package/client/composer-bridge/dom.ts +101 -0
- package/client/composer-bridge/effect.ts +64 -0
- package/client/composer-bridge/fiber.ts +97 -0
- package/client/composer-bridge/web.ts +58 -0
- package/client/icon.ts +13 -0
- package/client/pills/agent-pills.ts +207 -0
- package/client/pills/rewrite-runner.ts +123 -0
- package/client/settings/action-samples.ts +102 -0
- package/client/settings/api-endpoints.ts +156 -0
- package/client/settings/custom-actions.ts +79 -0
- package/client/settings/draft.ts +82 -0
- package/client/settings/model-filter.ts +33 -0
- package/client/settings/read-settings.ts +45 -0
- package/client/settings/readiness.ts +84 -0
- package/client/settings/sections/actions-section.tsx +75 -0
- package/client/settings/sections/advanced-section.tsx +127 -0
- package/client/settings/sections/api-endpoint-section.tsx +388 -0
- package/client/settings/sections/custom-actions-section.tsx +163 -0
- package/client/settings/sections/dedicated-model-section.tsx +136 -0
- package/client/settings/sections/engine-section.tsx +101 -0
- package/client/settings/sections/provider-map-card.tsx +89 -0
- package/client/settings/sections/stored-key-rows.tsx +106 -0
- package/client/settings/selection.ts +46 -0
- package/client/settings/settings-saved.ts +17 -0
- package/client/settings/settings-screen.tsx +197 -0
- package/client/settings/ui/button.tsx +56 -0
- package/client/settings/ui/notice.tsx +61 -0
- package/client/settings/ui/split-select.tsx +26 -0
- package/client/settings/ui/status-bar.tsx +89 -0
- package/client/settings/ui/tokens.ts +38 -0
- package/client/settings/validation.ts +50 -0
- package/client/sheet/rewrite-sheet.tsx +249 -0
- package/index.client.tsx +71 -0
- package/index.server.ts +98 -0
- package/package.json +53 -0
- package/paseo-plugin.json +6 -0
- package/server/log.ts +20 -0
- package/server/model-resolver/provider-catalog.ts +37 -0
- package/server/model-resolver/resolver.ts +196 -0
- package/server/paseo-types.ts +13 -0
- package/server/rewrite-engine/engine.ts +88 -0
- package/server/rewrite-engine/handler.ts +94 -0
- package/server/rewrite-engine/output-validator.ts +130 -0
- package/server/transports/api/anthropic.ts +61 -0
- package/server/transports/api/cloudflare.ts +52 -0
- package/server/transports/api/gemini.ts +62 -0
- package/server/transports/api/key.ts +95 -0
- package/server/transports/api/openai.ts +52 -0
- package/server/transports/api/protocol.ts +96 -0
- package/server/transports/api/runner.ts +284 -0
- package/server/transports/api/secrets-store.ts +90 -0
- package/server/transports/cli/family.ts +216 -0
- package/server/transports/cli/process.ts +118 -0
- package/server/transports/cli/runner.ts +89 -0
- package/shared/action-registry/loader.ts +63 -0
- package/shared/action-registry/registry.ts +47 -0
- package/shared/action-registry/rewrite-contract.ts +31 -0
- package/shared/action-registry/schema.ts +65 -0
- package/shared/action-registry/wrapper.ts +30 -0
- package/shared/api-protocol.ts +56 -0
- package/shared/cli-families.ts +29 -0
- package/shared/language-registry/loader.ts +53 -0
- package/shared/language-registry/registry.ts +20 -0
- package/shared/language-registry/schema.ts +21 -0
- package/shared/languages/en.json +6 -0
- package/shared/languages/index.ts +5 -0
- package/shared/languages/vi.json +6 -0
- package/shared/packs/general.json +17 -0
- package/shared/packs/index.ts +12 -0
- package/shared/protected-literals.ts +550 -0
- package/shared/rpc.ts +187 -0
- package/shared/settings.ts +90 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hungcuong9125
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# PromptKit
|
|
2
|
+
|
|
3
|
+
PromptKit is a Paseo plugin (id `prompt-kit`) that rewrites the prompt in your Composer. It adds one `PromptKit` pill to the Composer's track bar and a `/rewrite <prompt>` slash command; the default action is `General`. Running it rewrites the current Composer text in place, in your own voice (first person, speaking to the agent), keeps your language and every protected literal (URLs, absolute paths, shell commands, code blocks, model names, tool names), restores focus, and never sends the prompt. You review the result and send it yourself.
|
|
4
|
+
|
|
5
|
+
Rewriting has three paths, chosen in Settings. The default runs the selected model through the provider's own CLI, headlessly, in a temporary directory — no Paseo agent, no tab, no archive. Your primary conversation never receives a rewrite turn and never changes provider or session. The third path posts straight to an API you configure (OpenAI, Anthropic, Google Gemini, Cloudflare Workers AI, or anything speaking one of those protocols) when a CLI cold start is too slow or no CLI exists.
|
|
6
|
+
|
|
7
|
+
See [CHANGELOG.md](CHANGELOG.md) for release notes.
|
|
8
|
+
|
|
9
|
+
## Three ways to run it
|
|
10
|
+
|
|
11
|
+
- **The pill** — write your prompt, press `PromptKit`. The text is rewritten in place; a leading `/rewrite ` left in the text is ignored. Available once the agent exists, so on a new seat it appears after the first message.
|
|
12
|
+
- **`/rewrite <prompt>`** — type the command with the prompt after it and press Enter. Paseo empties the Composer and hands the text to PromptKit, which puts the `/rewrite …` line straight back, dims it with a light sweep while it works, and then replaces it with the rewrite. A failed rewrite leaves your line in place. Available immediately, including on a new seat before its first message. Because no agent exists yet on a draft, `/rewrite` cannot use `Current agent model`; choose `Dedicated model` or `Direct API` with a selected endpoint and model in Settings, or use the pill once the agent exists.
|
|
13
|
+
- **The PromptKit sheet** (mobile) — write in the Composer as usual and press the pill. A sheet slides up already holding your text and rewrites it at once. Press **Rewrite** again until it reads right, then **Send**: the message goes to the agent, the Composer is cleared, the sheet closes. **✕** closes the sheet and leaves the Composer untouched.
|
|
14
|
+
|
|
15
|
+
No path sends the message on its own. You review the result and send it yourself.
|
|
16
|
+
|
|
17
|
+
## Requirements
|
|
18
|
+
|
|
19
|
+
- Paseo Desktop or Web, version `>=0.9.0` (`paseo-plugin.json`). The plugin is built and tested against the 0.9.0 SDK.
|
|
20
|
+
- For the CLI transports: a provider/model reachable by the daemon, and that provider's CLI on the daemon's `PATH`.
|
|
21
|
+
- For the API transport: an endpoint URL, a model id, and a key — see [API keys](#api-keys).
|
|
22
|
+
|
|
23
|
+
## Install from a local directory
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
cd /path/to/paseo-prompt-kit
|
|
27
|
+
npm install
|
|
28
|
+
npm run typecheck
|
|
29
|
+
paseo plugin install /path/to/paseo-prompt-kit
|
|
30
|
+
paseo plugin ls
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`paseo plugin install` trusts the plugin: server code runs unsandboxed on the daemon host and client code runs inside Paseo. Reload after a code change:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
paseo plugin reload prompt-kit
|
|
37
|
+
paseo plugin logs prompt-kit
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Install from Git
|
|
41
|
+
|
|
42
|
+
After the repository is published, install the managed Git checkout:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
paseo plugin install hungcuong9125/paseo-prompt-kit --ref main
|
|
46
|
+
paseo plugin ls
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`--ref` chooses the initial branch, tag, or commit once; later `paseo plugin update prompt-kit` follows the remote's default HEAD. Pin a release instead of tracking `main` by giving `--ref` a tag:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
paseo plugin install hungcuong9125/paseo-prompt-kit --ref v0.5.2
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`paseo plugin ls` reports the installed commit.
|
|
56
|
+
|
|
57
|
+
## Settings
|
|
58
|
+
|
|
59
|
+
Open it from Paseo Settings → Plugins → the `…` menu on `prompt-kit` → **Settings**. The bar at the top says whether a rewrite would run and over which path, names the reason when it would not, and holds **Save** / **Discard** once something has changed. Nothing is written until Save.
|
|
60
|
+
|
|
61
|
+

|
|
62
|
+
|
|
63
|
+
The screen reads top to bottom in setup order:
|
|
64
|
+
|
|
65
|
+
1. **Actions** — one switch per action, bundled or custom; up to 6 can be on. One enabled action makes the pill rewrite at once; two or more make it a menu (on mobile, one button per action in the sheet); none hides the pill. A lone action shows no switch. Saving updates every pill at once.
|
|
66
|
+
**Custom actions** — your own actions, written as action-pack JSON in a text box on the same screen: **Add** opens a sample (Blank template, Copy of General, Execution brief, Plan first, Review request, Make concise, or a copy of one of yours), you change the id, title and instructions, **Apply** checks the JSON, and **Save** stores it. They run through the same rewrite as the bundled ones.
|
|
67
|
+
2. **Rewrite engine** — Transport, Model source (Provider CLI only) and Output language. Every other section appears only when these need it.
|
|
68
|
+
3. **Dedicated model** (CLI + Dedicated) or **API endpoint** (Direct API).
|
|
69
|
+
4. **Advanced** (collapsed) — timeout and the two per-provider overrides.
|
|
70
|
+
|
|
71
|
+
### Transport
|
|
72
|
+
|
|
73
|
+
- `Provider CLI` (default) — the provider's own CLI runs the rewrite headlessly. The model comes from **Model source**.
|
|
74
|
+
- `Direct API` — PromptKit posts to an endpoint you configure. No CLI is started, and there is no Model source: the model is chosen in the **API endpoint** section.
|
|
75
|
+
|
|
76
|
+
### Model source (Provider CLI only)
|
|
77
|
+
|
|
78
|
+
- `Current agent model` — the model the Composer's model control is showing for the agent whose pill you pressed. PromptKit reads the same value Paseo does (the provider session's runtime model first, then the configured model), so what you see is what runs.
|
|
79
|
+
- `Dedicated model` — a provider, model and thinking option you pick from the daemon's provider catalog, whatever the agent itself runs.
|
|
80
|
+
|
|
81
|
+
### Output language
|
|
82
|
+
|
|
83
|
+
`Same as the prompt` (default) keeps the language you wrote in. `English`, `Tiếng Việt`, or any language you add translates the prose while paths, commands, code and names stay exactly as written. A language is one JSON file under `shared/languages/`; see `docs/guides/output-languages.md`.
|
|
84
|
+
|
|
85
|
+
### The three paths
|
|
86
|
+
|
|
87
|
+
| Transport | Model source | What runs |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| `Provider CLI` | `Current agent model` | The agent's own provider CLI with the model the Composer shows |
|
|
90
|
+
| `Provider CLI` | `Dedicated model` | The dedicated provider's CLI with the model you picked |
|
|
91
|
+
| `Direct API` | — | An HTTP request to the endpoint and model chosen under **API endpoint** |
|
|
92
|
+
|
|
93
|
+
On `Direct API`, an agent whose provider is mapped under **Advanced → Endpoint per provider** sends its own model to that endpoint instead — this is how you point a provider such as `opencode` at your own OpenAI-compatible endpoint. Every other agent uses the endpoint and model from the **API endpoint** section; with no endpoint selected, only mapped providers can rewrite.
|
|
94
|
+
|
|
95
|
+
### API endpoint
|
|
96
|
+
|
|
97
|
+
Choose an endpoint — presets and your saved custom endpoints are listed A–Z (Anthropic, Cloudflare Workers AI, Google Gemini, Local server, OpenAI, OpenRouter), with **Custom endpoint…** last — fill in the base URL, pick a **Key source** (see [API keys](#api-keys)), and press **Test**. A successful test fills the **Model** list from the endpoint; the rewrite refuses a model outside that list. When a list has more than 8 models, a **Filter models** row above Model narrows the dropdown by name or id; the saved model always stays in it. Save is blocked while the endpoint cannot work (for example an empty base URL), with the reason in the status bar.
|
|
98
|
+
|
|
99
|
+

|
|
100
|
+
|
|
101
|
+
### Advanced
|
|
102
|
+
|
|
103
|
+
- `Timeout (ms)`: how long a rewrite may run, default `90000`, allowed range `1000`–`600000`. The daemon caps one plugin call at 30 s, so the screen notes when a budget above that cannot be reached.
|
|
104
|
+
- `CLI per provider` (Provider CLI): lists only the providers you have overridden, plus an Add row. A profile named after its CLI (`pi-peer`, `codex-lead`) resolves on its own and needs no entry; an unresolved provider is refused, never guessed.
|
|
105
|
+
- `Endpoint per provider` (Direct API): lists only mapped providers, plus an Add row. Pressing the pill in an agent of a mapped provider sends that agent's own model to the endpoint over HTTP. A mapped provider ignores the Model chosen under API endpoint.
|
|
106
|
+
|
|
107
|
+
Settings are host-scoped and persist across plugin reload.
|
|
108
|
+
|
|
109
|
+
## API keys
|
|
110
|
+
|
|
111
|
+
An API key is **never** stored in PromptKit's settings document. That document is read by the client — including the Paseo Web UI — so a key placed there would leave your machine. Instead each endpoint picks one **Key source** in the API endpoint section, and the daemon reads the value at request time from that source only. There is no fallback: a key missing from the chosen source fails closed with `missing_api_key`, the message names the variable and the place it looked, and the Composer text is untouched.
|
|
112
|
+
|
|
113
|
+
| Key source | Rows shown | Where the value is read |
|
|
114
|
+
|---|---|---|
|
|
115
|
+
| `Environment variable` (default) | Key variable | The daemon's environment variable with that name. The plugin server inherits the daemon's environment, so this suits a daemon started from a shell. |
|
|
116
|
+
| `secrets.json` | Key variable, Secrets directory, API key (optional); Cloudflare also Account ID | The entry with that name under `apiKeys` in `<Secrets directory>/secrets.json`. |
|
|
117
|
+
| `No key` | — | Nothing. For a local server (vLLM, llama.cpp, LM Studio). |
|
|
118
|
+
|
|
119
|
+
**Secrets directory** is shared by every endpoint that uses `secrets.json`. It must be an absolute path or start with `~/`. Empty means `<PASEO_HOME>/plugin-settings/prompt-kit`; `PASEO_HOME` defaults to `~/.paseo`, so on macOS the default file is `~/.paseo/plugin-settings/prompt-kit/secrets.json`.
|
|
120
|
+
|
|
121
|
+
After a successful **Test**, the Connection row says which source the key came from.
|
|
122
|
+
|
|
123
|
+
### Why the file exists at all
|
|
124
|
+
|
|
125
|
+
Paseo Desktop reads your login shell's environment **once, when it starts**, and the daemon keeps that copy. A key exported in `~/.zshrc` after Paseo started does not reach it until you quit and reopen Paseo; a daemon started some other way (a service, a remote host) may never see it. `secrets.json` is read at request time, so it works however and whenever the daemon was started.
|
|
126
|
+
|
|
127
|
+
### `secrets.json` format
|
|
128
|
+
|
|
129
|
+
One file holds every key: one entry per key under `apiKeys`, named whatever you like. Each endpoint whose Key source is `secrets.json` points at one entry through its **Key variable**, so several endpoints — or two accounts of one vendor, e.g. `GEMINI_WORK` and `GEMINI_PERSONAL` — share the file. Values must be strings; any other value is ignored.
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"version": 1,
|
|
134
|
+
"apiKeys": {
|
|
135
|
+
"OPENAI_API_KEY": "sk-...",
|
|
136
|
+
"ANTHROPIC_API_KEY": "sk-ant-...",
|
|
137
|
+
"GEMINI_API_KEY": "AIza..."
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Start from the template in this repository and keep it owner-only:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
mkdir -p ~/.paseo/plugin-settings/prompt-kit
|
|
146
|
+
chmod 700 ~/.paseo/plugin-settings/prompt-kit
|
|
147
|
+
cp docs/templates/secrets.template.json ~/.paseo/plugin-settings/prompt-kit/secrets.json
|
|
148
|
+
chmod 600 ~/.paseo/plugin-settings/prompt-kit/secrets.json
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Then replace each `replace_me`, delete the entries you do not use, and set the matching endpoints' Key source to `secrets.json`.
|
|
152
|
+
|
|
153
|
+
### Storing a key from Settings (convenience, least preferred)
|
|
154
|
+
|
|
155
|
+
> [!WARNING]
|
|
156
|
+
> Typing a key into the **API key** field sends the secret from the Paseo app to the daemon once, over the same connection Paseo uses. When you use Paseo Web or a client on another machine, the key crosses that link. Anyone who can open your Paseo settings can also overwrite or remove stored keys. Use this only on a machine and connection you trust.
|
|
157
|
+
|
|
158
|
+
Prefer, in this order:
|
|
159
|
+
|
|
160
|
+
1. **An environment variable** of the daemon (Key source `Environment variable`) — nothing leaves the daemon's machine and no file holds the key.
|
|
161
|
+
2. **Editing `secrets.json` yourself** on the daemon's machine, as above — the key never passes through the Paseo app.
|
|
162
|
+
3. **The API key field** — only when neither of the above is practical.
|
|
163
|
+
|
|
164
|
+
With Key source `secrets.json`, the API endpoint section shows an **API key** field (masked) and a **Store key** row. **Save** writes the value under the endpoint's Key variable in `secrets.json`: other entries and fields are kept, the directory is created `0700` and the file written `0600` through a temporary file and a rename, and a malformed file is refused rather than overwritten. The field is cleared after saving. The key is write-only: the screen only says whether a value is stored, and **Remove stored key** deletes that one entry. The value never enters the settings document, a log, or an RPC answer. Two clients saving at the same moment can overwrite each other's change.
|
|
165
|
+
|
|
166
|
+
### What PromptKit guarantees about keys
|
|
167
|
+
|
|
168
|
+
- The value is never written to the settings document, never returned by any RPC, and never written to a log or an error message. A failure names the **variable** (`The environment variable "GEMINI_API_KEY" is not set`), not the value.
|
|
169
|
+
- The key is read at request time and used for that one request only. It is not cached to disk.
|
|
170
|
+
- PromptKit writes `secrets.json` only when you press **Save** or **Remove** in the API key rows, and only the one entry named by the endpoint's Key variable. It never reads a value back to the app.
|
|
171
|
+
- An endpoint whose Key source is `No key` sends no credential. That is valid for a local server (vLLM, llama.cpp, LM Studio), which is why it is allowed rather than treated as an error.
|
|
172
|
+
|
|
173
|
+
### Endpoint configuration
|
|
174
|
+
|
|
175
|
+
Endpoints are defined in the settings document under `apiEndpoints`, because that is the only configuration store a Paseo plugin can read. A provider profile in `~/.paseo/config.json` is **not** visible to PromptKit: the daemon does not pass provider environment variables to plugins.
|
|
176
|
+
|
|
177
|
+
Each endpoint is one of four protocols. Adding a vendor that speaks one of them is a settings edit, not a code change:
|
|
178
|
+
|
|
179
|
+
| Protocol | Request | Works with |
|
|
180
|
+
|---|---|---|
|
|
181
|
+
| `openai` | `POST <baseUrl>/chat/completions`, `Authorization: Bearer` | OpenAI, OpenRouter, LiteLLM, vLLM, llama.cpp, LM Studio, Together, Fireworks, Groq, most gateways |
|
|
182
|
+
| `anthropic` | `POST <baseUrl>/v1/messages`, `x-api-key` | Anthropic, z.ai, Alibaba/Qwen, Anthropic-compatible gateways |
|
|
183
|
+
| `gemini` | `POST <baseUrl>/v1beta/models/<model>:generateContent`, `x-goog-api-key` | Google AI Studio, Vertex |
|
|
184
|
+
| `cloudflare` | `POST <baseUrl>/accounts/<account id>/ai/run/<model>`, `Authorization: Bearer` | Cloudflare Workers AI (`@cf/...` models) |
|
|
185
|
+
|
|
186
|
+
**Cloudflare Workers AI.** Base URL `https://api.cloudflare.com/client/v4`. The endpoint has an extra **Account ID** row, above Key variable, holding the **name** of the variable with your account id — prefilled `CLAUDFLARE_ACCOUNT_ID`, change it if yours differs. The account id is read from the same **Key source** as the token, so with `secrets.json` both live in the file and work however Paseo was started. The key variable defaults to `CLOUDFLARE_AUTH_TOKEN`, an API token with Workers AI permission. Models are the full `@cf/...` ids, for example `@cf/meta/llama-3.1-8b-instruct-fp8`; **Test** lists the account's text-generation models. PromptKit sends `max_tokens: 2048` because the service default of 256 tokens would cut a rewrite short.
|
|
187
|
+
|
|
188
|
+
Example — a Google Gemini endpoint with `gemini-2.5-flash-lite`, from the settings document:
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"transport": "api",
|
|
193
|
+
"apiEndpointId": "gemini",
|
|
194
|
+
"apiModel": "gemini-2.5-flash-lite",
|
|
195
|
+
"apiEndpoints": [
|
|
196
|
+
{
|
|
197
|
+
"id": "gemini",
|
|
198
|
+
"label": "Google Gemini",
|
|
199
|
+
"protocol": "gemini",
|
|
200
|
+
"baseUrl": "https://generativelanguage.googleapis.com",
|
|
201
|
+
"keySource": "env",
|
|
202
|
+
"apiKeyEnv": "GEMINI_API_KEY",
|
|
203
|
+
"models": ["gemini-2.5-flash-lite"]
|
|
204
|
+
}
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Example — point one provider at an endpoint, so it never pays a CLI cold start:
|
|
210
|
+
|
|
211
|
+
```json
|
|
212
|
+
{
|
|
213
|
+
"transport": "api",
|
|
214
|
+
"apiEndpointByProvider": { "opencode": "openrouter" }
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
An endpoint that lists `models` restricts the choice to that list, and a model outside it fails closed with `invalid_model` before any request. Leave `models` empty to accept any model id — correct for a local server whose list PromptKit cannot know.
|
|
219
|
+
|
|
220
|
+
### API failure codes
|
|
221
|
+
|
|
222
|
+
| Code | Meaning |
|
|
223
|
+
|---|---|
|
|
224
|
+
| `missing_api_key` | The endpoint's Key source has no value for its key variable, `secrets.json` is missing or malformed, or the secrets directory is not absolute |
|
|
225
|
+
| `api_endpoint_unknown` | The selected or mapped endpoint id is not defined in `apiEndpoints` |
|
|
226
|
+
| `api_http_error` | The endpoint answered a non-2xx status, or was unreachable |
|
|
227
|
+
| `api_bad_response` | The answer was not JSON, or carried no text |
|
|
228
|
+
| `timeout` | The request exceeded `Timeout (ms)` |
|
|
229
|
+
|
|
230
|
+
Every one of these leaves the Composer text untouched.
|
|
231
|
+
|
|
232
|
+
## Limitations
|
|
233
|
+
|
|
234
|
+
- In-place rewriting works on Desktop and Web only: the mobile app renders the Composer as a native text input with no DOM, and Paseo 0.9.0 exposes no plugin API for Composer text. On mobile the `PromptKit` pill opens the **PromptKit sheet** instead (see above); it reads and clears the Composer through the app's React tree rather than the DOM, which a Paseo update can break in the same way. Nothing is copied or sent on its own. `/rewrite` is not available on mobile. On Web, a refusal names what was found (no Composer, none visible, or more than one visible).
|
|
235
|
+
- Requires Paseo `>=0.9.0`. Because text access depends on the Composer DOM (or, on mobile, the React tree), a Paseo UI change can break it even when the public plugin SDK is compatible.
|
|
236
|
+
- No auto-send. PromptKit only replaces the Composer text; you send the message.
|
|
237
|
+
- PromptKit refuses to replace text you edited while a rewrite was running, and refuses when more than one Composer (or none) is visible.
|
|
238
|
+
- One bundled action: `General` turns the draft into a clear instruction the agent can act on — the concrete action, each constraint made checkable, the working steps for that kind of task (find the cause first, follow the codebase's existing way, keep the change scoped), and when it is done. It never invents files, numbers, requirements or decisions the draft does not contain, and it writes as you, speaking to the agent — never about "the user". Add your own under Settings → Custom actions, or bundle one as a JSON file — see `docs/EXTENDING.md`.
|
|
239
|
+
- The API transport does not stream: it makes one request and waits for the whole answer. A slow endpoint can exceed the daemon's 30-second plugin-call cap, in which case the host reports a timeout before PromptKit's own `Timeout (ms)` can fire.
|
|
240
|
+
- No OAuth or token refresh: an endpoint uses a static key. A provider that needs an interactive login is better served by the CLI transport.
|
|
241
|
+
|
|
242
|
+
## Project layout
|
|
243
|
+
|
|
244
|
+
The host compiler accepts only `client/`, `server/` and `shared/` at the root, so the modules live inside them. `docs/CORE.md` names each module's one responsibility; `docs/guides/` walks through the two data-only extensions (a new action pack, a new output language); `docs/EXTENDING.md` covers those plus API protocols, CLI families and settings sections, with templates under `docs/templates/`.
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT — see `LICENSE`. PromptKit contains no copied upstream code. The Composer text access pattern follows [paseo-emoji](https://github.com/YoseptF/paseo-emoji) (MIT); the plugin runs on [Paseo](https://github.com/getpaseo/paseo) (Apache-2.0) through its public plugin SDK.
|
|
249
|
+
</content>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ActionSummary } from "../../shared/rpc.js";
|
|
2
|
+
import type { PromptKitSettings } from "../../shared/settings.js";
|
|
3
|
+
|
|
4
|
+
/** The most actions the pill menu and the mobile sheet show at once. */
|
|
5
|
+
export const MAX_ENABLED_ACTIONS = 6;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The locked UX: `E` is the set of loaded actions the user has enabled.
|
|
9
|
+
*
|
|
10
|
+
* A single enabled action is a direct action button, two or more are a menu, and
|
|
11
|
+
* none hides the pill. Ordering follows the registry so the pill does not depend
|
|
12
|
+
* on settings-object key order.
|
|
13
|
+
*/
|
|
14
|
+
export function enabledActions(
|
|
15
|
+
actions: readonly ActionSummary[],
|
|
16
|
+
settings: PromptKitSettings,
|
|
17
|
+
): readonly ActionSummary[] {
|
|
18
|
+
return actions.filter((action) => settings.actionEnabled[action.id] ?? action.enabledByDefault);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Why the enabled set cannot be saved, or null. */
|
|
22
|
+
export function describeEnabledLimit(
|
|
23
|
+
actions: readonly ActionSummary[],
|
|
24
|
+
settings: PromptKitSettings,
|
|
25
|
+
): string | null {
|
|
26
|
+
const count = enabledActions(actions, settings).length;
|
|
27
|
+
return count > MAX_ENABLED_ACTIONS
|
|
28
|
+
? `${count} actions are enabled; turn some off to keep at most ${MAX_ENABLED_ACTIONS}.`
|
|
29
|
+
: null;
|
|
30
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
2
|
+
import type { ActionPack } from "../../shared/action-registry/schema.js";
|
|
3
|
+
import type { ActionSummary } from "../../shared/rpc.js";
|
|
4
|
+
import { enabledActions } from "../actions/enabled.js";
|
|
5
|
+
import { createWebComposerAdapter } from "../composer-bridge/web.js";
|
|
6
|
+
import { createRewriteRunner } from "../pills/rewrite-runner.js";
|
|
7
|
+
import { createSettingsReader, type SettingsRead } from "../settings/read-settings.js";
|
|
8
|
+
|
|
9
|
+
export const REWRITE_COMMAND = "rewrite";
|
|
10
|
+
|
|
11
|
+
export interface RewriteCommandDependencies {
|
|
12
|
+
listActions: (customActions: readonly ActionPack[]) => Promise<readonly ActionSummary[]>;
|
|
13
|
+
readSettings: () => Promise<SettingsRead>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `/rewrite <prompt>` (Desktop/Web). Workspace scope so a draft seat (no agent
|
|
18
|
+
* yet) has it; hence agentId is null and "current agent model" refuses. Native
|
|
19
|
+
* mobile has no Composer DOM: the pill opens a sheet there instead.
|
|
20
|
+
*/
|
|
21
|
+
export function registerRewriteCommand(
|
|
22
|
+
client: PluginClientContext,
|
|
23
|
+
dependencies: RewriteCommandDependencies,
|
|
24
|
+
): () => void {
|
|
25
|
+
return client.addSlashCommand({
|
|
26
|
+
name: REWRITE_COMMAND,
|
|
27
|
+
description: "Rewrite the prompt with PromptKit and put the result back in the Composer",
|
|
28
|
+
argumentHint: "<prompt>",
|
|
29
|
+
context: "workspace",
|
|
30
|
+
async onSubmit(context) {
|
|
31
|
+
const adapter = createWebComposerAdapter();
|
|
32
|
+
if (!adapter.isSupported()) {
|
|
33
|
+
throw new Error("On mobile, press the PromptKit pill instead.");
|
|
34
|
+
}
|
|
35
|
+
const settings = await dependencies.readSettings();
|
|
36
|
+
if (settings.status !== "ready") throw new Error(settings.error);
|
|
37
|
+
const actions = await dependencies.listActions(settings.values.customActions);
|
|
38
|
+
// Default flow = first enabled action.
|
|
39
|
+
const action = enabledActions(actions, settings.values)[0];
|
|
40
|
+
if (action === undefined) {
|
|
41
|
+
throw new Error("No PromptKit action is enabled. Enable one in PromptKit settings.");
|
|
42
|
+
}
|
|
43
|
+
const runner = createRewriteRunner({
|
|
44
|
+
adapter,
|
|
45
|
+
rpc: context.rpc,
|
|
46
|
+
readSettings: createSettingsReader(context.rpc),
|
|
47
|
+
agentId: null,
|
|
48
|
+
workspaceId: context.workspace.id,
|
|
49
|
+
isActive: () => true,
|
|
50
|
+
});
|
|
51
|
+
await runner.runText(action.id, context.args);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform-neutral Composer surface. The rest of the plugin never sees a DOM
|
|
3
|
+
* selector; only `client/composer-bridge/web.ts` implements this.
|
|
4
|
+
*/
|
|
5
|
+
export interface ComposerAdapter {
|
|
6
|
+
/** False where no DOM exists (native mobile). */
|
|
7
|
+
isSupported(): boolean;
|
|
8
|
+
readText(): string | null;
|
|
9
|
+
replaceText(next: string): boolean;
|
|
10
|
+
focus(): void;
|
|
11
|
+
/** Why `readText` returned null, for the user-facing message. */
|
|
12
|
+
describeFailure(): string;
|
|
13
|
+
/** Visual "rewriting" state on the Composer; returns the function that ends it. */
|
|
14
|
+
beginRewriteEffect(): () => void;
|
|
15
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Paseo has no public plugin API for Composer text, so Desktop/Web attach to the
|
|
2
|
+
// DOM. Selectors come from the app source, never from positional structure.
|
|
3
|
+
// `[data-testid="message-input-root"]` - packages/app/src/composer/input/input.tsx:1787
|
|
4
|
+
// `textarea[data-composer-input]` - dataSet={COMPOSER_INPUT_DATASET} at input.tsx:670
|
|
5
|
+
// from input.tsx:87, and consumed by
|
|
6
|
+
// packages/app/src/styles/install-web-scrollbar-styles.web.ts:22
|
|
7
|
+
export const COMPOSER_ROOT_SELECTOR = '[data-testid="message-input-root"]';
|
|
8
|
+
export const COMPOSER_INPUT_SELECTOR = "textarea[data-composer-input]";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* An inactive pane stays mounted inside `RetainedPanel` and is hidden at an
|
|
12
|
+
* **ancestor** with `display: none`
|
|
13
|
+
* (packages/app/src/components/retained-panel.tsx:66-67). That removes the whole
|
|
14
|
+
* subtree from rendering, but the descendant's own computed `display` is still
|
|
15
|
+
* `flex`, so reading the element alone reports every retained Composer as
|
|
16
|
+
* visible. `display` is not inherited, so the ancestor chain must be walked.
|
|
17
|
+
*
|
|
18
|
+
* `visibility` is checked along the same chain: the host hides retained panes
|
|
19
|
+
* with `display`, and a stricter walk can only refuse more, never rewrite a
|
|
20
|
+
* Composer it cannot prove the user sees.
|
|
21
|
+
*/
|
|
22
|
+
export function isElementVisible(element: Element): boolean {
|
|
23
|
+
if (!element.isConnected) return false;
|
|
24
|
+
const view = element.ownerDocument.defaultView;
|
|
25
|
+
if (!view) return true;
|
|
26
|
+
for (let node: Element | null = element; node !== null; node = node.parentElement) {
|
|
27
|
+
const style = view.getComputedStyle(node);
|
|
28
|
+
if (style.display === "none") return false;
|
|
29
|
+
if (style.visibility === "hidden" || style.visibility === "collapse") return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the one Composer the user can see. Exactly one visible root carrying
|
|
36
|
+
* exactly one visible field must be provable; any other topology returns null so
|
|
37
|
+
* the caller refuses instead of rewriting a Composer it cannot identify.
|
|
38
|
+
*/
|
|
39
|
+
export interface ComposerTopology {
|
|
40
|
+
roots: number;
|
|
41
|
+
visibleRoots: number;
|
|
42
|
+
fields: number;
|
|
43
|
+
visibleFields: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Counts the Composer roots and fields, for diagnostics. */
|
|
47
|
+
export function inspectComposers(root: ParentNode = document): ComposerTopology {
|
|
48
|
+
const roots = Array.from(root.querySelectorAll(COMPOSER_ROOT_SELECTOR));
|
|
49
|
+
const visibleRoots = roots.filter(isElementVisible);
|
|
50
|
+
const fields = visibleRoots.flatMap((entry) =>
|
|
51
|
+
Array.from(entry.querySelectorAll<HTMLTextAreaElement>(COMPOSER_INPUT_SELECTOR)),
|
|
52
|
+
);
|
|
53
|
+
return {
|
|
54
|
+
roots: roots.length,
|
|
55
|
+
visibleRoots: visibleRoots.length,
|
|
56
|
+
fields: fields.length,
|
|
57
|
+
visibleFields: fields.filter(isElementVisible).length,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function describeComposerTopology(topology: ComposerTopology): string {
|
|
62
|
+
if (topology.roots === 0) return "PromptKit found no Composer on this screen.";
|
|
63
|
+
if (topology.visibleRoots === 0) {
|
|
64
|
+
return `PromptKit found ${topology.roots} Composer(s) but none is visible.`;
|
|
65
|
+
}
|
|
66
|
+
if (topology.visibleRoots > 1) {
|
|
67
|
+
return `PromptKit found ${topology.visibleRoots} visible Composers and cannot tell which one to rewrite.`;
|
|
68
|
+
}
|
|
69
|
+
if (topology.visibleFields === 0) return "PromptKit found the Composer but not its text field.";
|
|
70
|
+
return `PromptKit found ${topology.visibleFields} text fields in the Composer.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function locateComposerField(root: ParentNode = document): HTMLTextAreaElement | null {
|
|
74
|
+
const roots = Array.from(root.querySelectorAll(COMPOSER_ROOT_SELECTOR)).filter(isElementVisible);
|
|
75
|
+
if (roots.length !== 1) return null;
|
|
76
|
+
const onlyRoot = roots[0];
|
|
77
|
+
if (!onlyRoot) return null;
|
|
78
|
+
const fields = Array.from(
|
|
79
|
+
onlyRoot.querySelectorAll<HTMLTextAreaElement>(COMPOSER_INPUT_SELECTOR),
|
|
80
|
+
).filter(isElementVisible);
|
|
81
|
+
if (fields.length !== 1) return null;
|
|
82
|
+
return fields[0] ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* React tracks the value it last saw, so a plain assignment is invisible to
|
|
87
|
+
* `onChange`. The native setter updates the DOM without tripping React's own
|
|
88
|
+
* value tracking, and the bubbling `input` event is what React listens to.
|
|
89
|
+
*/
|
|
90
|
+
export function setNativeValue(field: HTMLTextAreaElement, value: string): void {
|
|
91
|
+
let prototype: object | null = Object.getPrototypeOf(field) as object | null;
|
|
92
|
+
while (prototype) {
|
|
93
|
+
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
|
94
|
+
if (setter) {
|
|
95
|
+
setter.call(field, value);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
prototype = Object.getPrototypeOf(prototype) as object | null;
|
|
99
|
+
}
|
|
100
|
+
field.value = value;
|
|
101
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { COMPOSER_ROOT_SELECTOR } from "./dom.js";
|
|
2
|
+
|
|
3
|
+
const STYLE_ID = "prompt-kit-effect";
|
|
4
|
+
const DIM_OPACITY = "0.45";
|
|
5
|
+
const FADE_MS = 220;
|
|
6
|
+
|
|
7
|
+
function ensureStyles(doc: Document): void {
|
|
8
|
+
if (doc.getElementById(STYLE_ID)) return;
|
|
9
|
+
const style = doc.createElement("style");
|
|
10
|
+
style.id = STYLE_ID;
|
|
11
|
+
style.textContent = `
|
|
12
|
+
@keyframes prompt-kit-sweep { from { transform: translateX(-140%) skewX(-20deg); } to { transform: translateX(340%) skewX(-20deg); } }
|
|
13
|
+
.prompt-kit-overlay { position: absolute; inset: 0; pointer-events: none; overflow: hidden; border-radius: inherit; }
|
|
14
|
+
.prompt-kit-beam { position: absolute; top: -10%; bottom: -10%; left: 0; width: 40%;
|
|
15
|
+
background: linear-gradient(90deg, transparent, var(--prompt-kit-beam, rgba(255,255,255,0.18)), transparent);
|
|
16
|
+
animation: prompt-kit-sweep 1.1s linear infinite; }
|
|
17
|
+
@media (prefers-reduced-motion: reduce) { .prompt-kit-beam { animation: none; display: none; } }
|
|
18
|
+
`;
|
|
19
|
+
doc.head.appendChild(style);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Beam color from the background behind the field: light on dark surfaces, dark on light ones. */
|
|
23
|
+
function beamColor(element: HTMLElement | null): string {
|
|
24
|
+
const view = element?.ownerDocument.defaultView;
|
|
25
|
+
for (let node = element; node && view; node = node.parentElement) {
|
|
26
|
+
const match = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/.exec(view.getComputedStyle(node).backgroundColor);
|
|
27
|
+
if (!match || (match[4] !== undefined && Number(match[4]) === 0)) continue;
|
|
28
|
+
const luminance = (0.2126 * Number(match[1]) + 0.7152 * Number(match[2]) + 0.0722 * Number(match[3])) / 255;
|
|
29
|
+
return luminance > 0.5 ? "rgba(0,0,0,0.12)" : "rgba(255,255,255,0.18)";
|
|
30
|
+
}
|
|
31
|
+
return "rgba(128,128,128,0.18)";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Dims the field and sweeps a slanted light across its Composer until `stop` is called. */
|
|
35
|
+
export function startRewriteEffect(field: HTMLTextAreaElement): () => void {
|
|
36
|
+
const doc = field.ownerDocument;
|
|
37
|
+
ensureStyles(doc);
|
|
38
|
+
const root = field.closest<HTMLElement>(COMPOSER_ROOT_SELECTOR) ?? field.parentElement;
|
|
39
|
+
const previousTransition = field.style.transition;
|
|
40
|
+
const previousOpacity = field.style.opacity;
|
|
41
|
+
field.style.transition = `opacity ${FADE_MS}ms ease`;
|
|
42
|
+
field.style.opacity = DIM_OPACITY;
|
|
43
|
+
|
|
44
|
+
const overlay = doc.createElement("div");
|
|
45
|
+
overlay.className = "prompt-kit-overlay";
|
|
46
|
+
overlay.setAttribute("data-prompt-kit-effect", "");
|
|
47
|
+
overlay.style.setProperty("--prompt-kit-beam", beamColor(root));
|
|
48
|
+
const beam = doc.createElement("div");
|
|
49
|
+
beam.className = "prompt-kit-beam";
|
|
50
|
+
overlay.appendChild(beam);
|
|
51
|
+
root?.appendChild(overlay);
|
|
52
|
+
|
|
53
|
+
let stopped = false;
|
|
54
|
+
return () => {
|
|
55
|
+
if (stopped) return;
|
|
56
|
+
stopped = true;
|
|
57
|
+
overlay.remove();
|
|
58
|
+
field.style.opacity = previousOpacity || "1";
|
|
59
|
+
setTimeout(() => {
|
|
60
|
+
field.style.transition = previousTransition;
|
|
61
|
+
if (previousOpacity === "") field.style.removeProperty("opacity");
|
|
62
|
+
}, FADE_MS + 40);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host's `MessageInputRef`: its `replaceText` also updates the Composer's
|
|
3
|
+
* own state (live-text presence, draft), which the inner text field's handle
|
|
4
|
+
* does not. `getInputSnapshot` is the marker that tells the two apart.
|
|
5
|
+
*/
|
|
6
|
+
export interface ComposerTextHandle {
|
|
7
|
+
getText(): string;
|
|
8
|
+
replaceText(text: string): void;
|
|
9
|
+
focus(): void;
|
|
10
|
+
getInputSnapshot(): unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface FiberLike {
|
|
14
|
+
return: FiberLike | null;
|
|
15
|
+
child: FiberLike | null;
|
|
16
|
+
sibling: FiberLike | null;
|
|
17
|
+
memoizedProps: Record<string, unknown> | null;
|
|
18
|
+
ref?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const MAX_VISITED = 50_000;
|
|
22
|
+
|
|
23
|
+
/** Fabric public instances carry their fiber; anything else yields null. */
|
|
24
|
+
export function fiberOf(instance: unknown): FiberLike | null {
|
|
25
|
+
if (instance === null || typeof instance !== "object") return null;
|
|
26
|
+
const fiber = (instance as { __internalInstanceHandle?: unknown }).__internalInstanceHandle;
|
|
27
|
+
return fiber !== null && typeof fiber === "object" && "return" in fiber ? (fiber as FiberLike) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isMessageInputHandle(current: unknown): current is ComposerTextHandle {
|
|
31
|
+
if (current === null || typeof current !== "object") return false;
|
|
32
|
+
const handle = current as Partial<ComposerTextHandle>;
|
|
33
|
+
return (
|
|
34
|
+
typeof handle.getText === "function" &&
|
|
35
|
+
typeof handle.replaceText === "function" &&
|
|
36
|
+
typeof handle.focus === "function" &&
|
|
37
|
+
typeof handle.getInputSnapshot === "function"
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function handleOf(fiber: FiberLike): ComposerTextHandle | null {
|
|
42
|
+
for (const ref of [fiber.ref, fiber.memoizedProps?.ref]) {
|
|
43
|
+
const current = (ref as { current?: unknown } | null | undefined)?.current;
|
|
44
|
+
if (isMessageInputHandle(current)) return current;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function belongsToAgent(fiber: FiberLike, agentId: string): boolean {
|
|
50
|
+
for (let node: FiberLike | null = fiber; node !== null; node = node.return) {
|
|
51
|
+
if (node.memoizedProps?.agentId === agentId) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ComposerLookup =
|
|
57
|
+
| { ok: true; handle: ComposerTextHandle }
|
|
58
|
+
| { ok: false; reason: "no_fiber" | "not_found" | "ambiguous" };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Finds the Composer of `agentId` from any fiber in the same React tree: up to
|
|
62
|
+
* the root, then down for the element whose ref is a `MessageInputRef`, under
|
|
63
|
+
* an ancestor whose props name the agent.
|
|
64
|
+
*/
|
|
65
|
+
export function findComposerHandle(start: FiberLike | null, agentId: string): ComposerLookup {
|
|
66
|
+
if (start === null) return { ok: false, reason: "no_fiber" };
|
|
67
|
+
let root: FiberLike = start;
|
|
68
|
+
while (root.return !== null) root = root.return;
|
|
69
|
+
|
|
70
|
+
const handles: ComposerTextHandle[] = [];
|
|
71
|
+
const stack: FiberLike[] = [root];
|
|
72
|
+
let visited = 0;
|
|
73
|
+
while (stack.length > 0 && visited < MAX_VISITED) {
|
|
74
|
+
const fiber = stack.pop()!;
|
|
75
|
+
visited += 1;
|
|
76
|
+
const handle = handleOf(fiber);
|
|
77
|
+
if (handle !== null && !handles.includes(handle) && belongsToAgent(fiber, agentId)) {
|
|
78
|
+
handles.push(handle);
|
|
79
|
+
}
|
|
80
|
+
if (fiber.sibling) stack.push(fiber.sibling);
|
|
81
|
+
if (fiber.child) stack.push(fiber.child);
|
|
82
|
+
}
|
|
83
|
+
if (handles.length === 1) return { ok: true, handle: handles[0]! };
|
|
84
|
+
if (handles.length > 1) return { ok: false, reason: "ambiguous" };
|
|
85
|
+
return { ok: false, reason: "not_found" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function describeLookupFailure(reason: Exclude<ComposerLookup, { ok: true }>["reason"]): string {
|
|
89
|
+
switch (reason) {
|
|
90
|
+
case "no_fiber":
|
|
91
|
+
return "PromptKit could not reach the app's component tree on this host.";
|
|
92
|
+
case "not_found":
|
|
93
|
+
return "PromptKit could not find this agent's Composer.";
|
|
94
|
+
case "ambiguous":
|
|
95
|
+
return "PromptKit found more than one Composer for this agent.";
|
|
96
|
+
}
|
|
97
|
+
}
|