paseo-form-render 0.0.0-stage → 0.1.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 quickwind
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 CHANGED
@@ -1,3 +1,114 @@
1
- # Temporary Holding Version
1
+ # Paseo Form Render
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Interactive forms, dashboards, and metrics rendering plugin for Paseo conversations.
4
+
5
+ ## Features
6
+
7
+ - **Interactive Forms in Chat**: Renders structured forms directly in the timeline from ````pfr-form```` code fences.
8
+ - **Rich Component Catalog**:
9
+ - `Card`: Container with title, description, and child items
10
+ - `Metric`: Key-value statistics display
11
+ - `Input`: Text input with placeholders and required validation
12
+ - `Select`: Dropdown selection menus with customizable options
13
+ - `Toggle`: Boolean switches
14
+ - `Button`: Submit and action buttons with custom payloads
15
+ - **Provider Agnostic**:
16
+ - Fully compatible with **Devin**, **Claude**, **Antigravity**, **Gemini**, and OpenAI providers.
17
+ - Automatically injects protocol instructions on agent initialization.
18
+ - **Full Mobile & Hermes Compatibility**:
19
+ - 100% pure React Native UI primitives (`View`, `Text`, `TextInput`, `Pressable`, `Switch`).
20
+ - Zero external runtime dependencies in the client bundle.
21
+ - Rigorously tested against the React Native Hermes JavaScript engine on iOS and Android.
22
+ - **Self-healing Forms**:
23
+ - Invalid `pfr-form` blocks show a precise error list (offending node, missing fields, allowed values, "did you mean" hints, JSON line/column).
24
+ - The agent is automatically asked to re-send a corrected block, up to a configurable number of times in a row.
25
+ - An **Ask agent to fix** button on the error box requests a fix manually (ignores the retry limit).
26
+ - Setting: **Paseo Form Render > Maximum retries** in the plugin settings (0-5, default 2; 0 turns automatic fixes off).
27
+ - Known limitations:
28
+ - A message sent within a few milliseconds of the agent finishing (by a script, another plugin, or an orchestrating agent) can race the automatic fix request. Paseo's `send` interrupts a running turn and has no "send only if idle" option, so in that race the fix may cancel the other message's turn. The plugin re-checks that the agent is idle right before sending, which narrows the window; messages typed by a person are not affected in practice.
29
+ - Not used yet: sending the fix with `activeTurnBehavior: "steer"` would add it to a running turn instead of cancelling that turn, but the agent then handles an unrelated fix request in the middle of another reply, and providers without steering support (ACP-based ones such as Copilot, Cursor, Kimi) fall back to interrupting anyway.
30
+ - Each assistant message is checked on its own, as the client renders it. A provider that splits one `pfr-form` fence across several messages gets a (misleading) JSON error and a fix request.
31
+ - **Prefer forms** (on by default): the **Paseo Form Render > Prefer forms** plugin setting adds a "Required usage" section to the injected prompt, so agents must ask for choices, parameters, and confirmations with an `pfr-form` block instead of plain text. Turn it off to keep the weaker default wording, which only says agents can use forms. Applies to agents created after the change.
32
+ - **Non-blocking `/form-demo`**:
33
+ - Pushes sample forms directly via daemon RPC without triggering model thinking turns or consuming API quota.
34
+
35
+ ## Slash Commands
36
+
37
+ - `/form [task]`: Instruct the active agent to adopt the `pfr-form` protocol for interactive questionnaires or parameter collection.
38
+ - `/form-demo`: Display a demonstration interactive configuration form in the conversation timeline without calling the model.
39
+
40
+ ## Installation
41
+
42
+ ### From npm
43
+ ```bash
44
+ paseo plugin add npm:paseo-form-render
45
+ ```
46
+
47
+ That is the whole install. Paseo fetches the package and compiles the TypeScript sources with its
48
+ own bundler, so the plugin ships no build artifacts and has no runtime dependencies.
49
+
50
+ ### From Local Directory
51
+ ```bash
52
+ paseo plugin add /path/to/paseo-form-render
53
+ ```
54
+
55
+ ### From Offline Package
56
+ Unpack `paseo-form-render-<version>-offline.zip` and run:
57
+ ```bash
58
+ paseo plugin add /path/to/extracted-folder
59
+ ```
60
+ Or register in `~/.paseo/config.json`:
61
+ ```json
62
+ {
63
+ "plugins": {
64
+ "paseo-form-render": {
65
+ "source": "directory",
66
+ "path": "/path/to/paseo-form-render",
67
+ "enabled": true
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ ## Development & Packaging
74
+
75
+ The npm package is the primary release channel: the tarball ships the TypeScript sources plus
76
+ `paseo-plugin.json` (the negated `files` pattern keeps the `*.test.ts` files out), and Paseo compiles
77
+ them itself. Releases run from the **Publish** workflow by hand (Actions tab → Publish → Run workflow,
78
+ or `gh workflow run Publish -f version=0.1.1`): it checks the version against `package.json`, re-runs
79
+ typecheck and the unit tests, publishes with the `NPM_TOKEN` secret, then pushes the `v<version>` tag
80
+ and opens the GitHub release. Pass `dry_run` to verify without publishing.
81
+
82
+ Nothing runs automatically — this repository is private, so Actions minutes are billed. CI (unit +
83
+ e2e on three platforms) and the Snyk scan are manual too.
84
+
85
+ ```bash
86
+ # Typecheck and run test suites
87
+ npm run typecheck
88
+ npm test
89
+
90
+ # Inspect exactly what the npm tarball would contain
91
+ npm pack --dry-run
92
+
93
+ # Build offline archive (outputs to ./dist by default)
94
+ npm run pack:offline
95
+
96
+ # Or specify a custom output directory
97
+ npm run pack:offline -- /path/to/dist
98
+ # or: DIST_DIR=/path/to/dist npm run pack:offline
99
+
100
+ # End-to-end tests against a real Paseo daemon (Linux, macOS, Windows; Node 22+)
101
+ npm ci --prefix e2e
102
+ npx --prefix e2e playwright install chromium # once, for the browser UI checks
103
+ npm run e2e # or: node e2e/run.mjs --all --repeat 2 (--no-ui: skip the browser)
104
+ ```
105
+
106
+ The e2e harness lives in `e2e/` with its own dependencies and is not part of the plugin package; see
107
+ [`e2e/README.md`](e2e/README.md). Besides the auto-fix protocol it checks the client UI (rendered
108
+ forms, the error box and its "Ask agent to fix" button, the settings screen) in the daemon's web UI
109
+ with headless Chromium and saves screenshots. CI (`.github/workflows/ci.yml`) runs the unit tests and
110
+ the e2e suite on Linux, macOS and Windows.
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,286 @@
1
+ import type { PluginHostProps } from "@getpaseo/plugin/client";
2
+ import React, { useMemo, useState } from "react";
3
+ import { View, Text, StyleSheet, Pressable, Platform } from "react-native";
4
+ import { formatIssues } from "../shared/form-errors";
5
+ import { buildFixMessage } from "../shared/form-fix";
6
+ import { parseFormBlock, parseMessageContent, type ParsedBlock } from "../shared/parser";
7
+ import { PfrFormView } from "./renderer";
8
+ import { HostMarkdown } from "./host-markdown";
9
+ import { PfrRpcProvider, type PfrRpcFn } from "./rpc-bridge";
10
+
11
+ export interface MessageContainerProps {
12
+ text: string;
13
+ theme: PluginHostProps["theme"];
14
+ agentId?: string;
15
+ rpc?: PfrRpcFn;
16
+ onSubmit: (formattedMessage: string) => Promise<void> | void;
17
+ }
18
+
19
+ export interface FormBlockViewProps {
20
+ rawJson: string;
21
+ blockNumber: number;
22
+ theme: PluginHostProps["theme"];
23
+ agentId?: string;
24
+ rpc?: PfrRpcFn;
25
+ onSubmit: (formattedMessage: string) => Promise<void> | void;
26
+ }
27
+
28
+ interface FormErrorBoxProps {
29
+ block: ParsedBlock;
30
+ blockNumber: number;
31
+ theme: PluginHostProps["theme"];
32
+ onSubmit: MessageContainerProps["onSubmit"];
33
+ }
34
+
35
+ interface BlockErrorBoundaryProps {
36
+ fallbackText?: string;
37
+ children: React.ReactNode;
38
+ }
39
+
40
+ interface BlockErrorBoundaryState {
41
+ hasError: boolean;
42
+ errorMessage: string;
43
+ }
44
+
45
+ // ES5 class component for Hermes compatibility (Hermes throws syntax error on ES6 `class` keyword).
46
+ const BlockErrorBoundary: React.ComponentClass<BlockErrorBoundaryProps, BlockErrorBoundaryState> = function (
47
+ this: any,
48
+ props: BlockErrorBoundaryProps
49
+ ) {
50
+ (React.Component as any).call(this, props);
51
+ this.state = { hasError: false, errorMessage: "" };
52
+ } as any;
53
+ BlockErrorBoundary.prototype = Object.create(React.Component.prototype);
54
+ BlockErrorBoundary.prototype.constructor = BlockErrorBoundary;
55
+ (BlockErrorBoundary as any).getDerivedStateFromError = function (error: any): BlockErrorBoundaryState {
56
+ const msg = error instanceof Error ? error.message : String(error);
57
+ return { hasError: true, errorMessage: msg };
58
+ };
59
+ BlockErrorBoundary.prototype.componentDidCatch = function (error: any, info: any) {
60
+ console.warn("[paseo-form-render] Block render failed:", error, info?.componentStack);
61
+ };
62
+ BlockErrorBoundary.prototype.render = function (this: any) {
63
+ if (this.state.hasError) {
64
+ return (
65
+ <View style={[styles.errorBox, { borderColor: "#ef4444", backgroundColor: "#fee2e2" }]}>
66
+ <Text style={[styles.errorTitle, { color: "#dc2626" }]}>
67
+ [UI Render Error]
68
+ </Text>
69
+ <Text style={[styles.errorDetail, { color: "#991b1b" }]}>
70
+ {this.state.errorMessage}
71
+ </Text>
72
+ {this.props.fallbackText ? (
73
+ <View style={styles.rawJsonBox}>
74
+ <Text style={[styles.rawJsonText, { color: "#1f2937" }]}>
75
+ {this.props.fallbackText}
76
+ </Text>
77
+ </View>
78
+ ) : null}
79
+ </View>
80
+ );
81
+ }
82
+ return this.props.children;
83
+ };
84
+
85
+ const FormErrorBox: React.FC<FormErrorBoxProps> = ({ block, blockNumber, theme, onSubmit }) => {
86
+ const [status, setStatus] = useState<"idle" | "sending" | "sent">("idle");
87
+ const errors = block.errors ?? [];
88
+
89
+ const askAgentToFix = async () => {
90
+ setStatus("sending");
91
+ try {
92
+ await onSubmit(buildFixMessage([{ issues: errors, blockNumber }]));
93
+ setStatus("sent");
94
+ } catch (e) {
95
+ setStatus("idle");
96
+ }
97
+ };
98
+
99
+ return (
100
+ <View
101
+ style={[
102
+ styles.errorBox,
103
+ {
104
+ backgroundColor: theme.colors.surface2,
105
+ borderColor: theme.colors.statusDanger,
106
+ },
107
+ ]}
108
+ >
109
+ <Text style={[styles.errorTitle, { color: theme.colors.statusDanger }]}>
110
+ [pfr-form Syntax Error]
111
+ </Text>
112
+ {formatIssues(errors).map((line, i) => (
113
+ <Text key={i} style={[styles.errorDetail, { color: theme.colors.statusDanger }]}>
114
+ {line}
115
+ </Text>
116
+ ))}
117
+ <View
118
+ style={[
119
+ styles.rawJsonBox,
120
+ {
121
+ backgroundColor: theme.colors.surface1,
122
+ },
123
+ ]}
124
+ >
125
+ <Text
126
+ style={[
127
+ styles.rawJsonText,
128
+ {
129
+ color: theme.colors.foreground,
130
+ },
131
+ ]}
132
+ >
133
+ {block.rawJson}
134
+ </Text>
135
+ </View>
136
+ {status === "sent" ? (
137
+ <Text style={[styles.sentText, { color: theme.colors.statusSuccess }]}>
138
+ ✓ Fix requested; the agent will reply with a corrected form
139
+ </Text>
140
+ ) : (
141
+ <Pressable
142
+ disabled={status !== "idle"}
143
+ style={[
144
+ styles.fixButton,
145
+ { backgroundColor: theme.colors.accent },
146
+ status !== "idle" && { opacity: 0.5 },
147
+ ]}
148
+ onPress={askAgentToFix}
149
+ >
150
+ <Text style={[styles.fixButtonText, { color: theme.colors.accentForeground }]}>
151
+ {status === "sending" ? "Sending…" : "Ask agent to fix"}
152
+ </Text>
153
+ </Pressable>
154
+ )}
155
+ </View>
156
+ );
157
+ };
158
+
159
+ export const FormBlockView: React.FC<FormBlockViewProps> = ({
160
+ rawJson,
161
+ blockNumber,
162
+ theme,
163
+ agentId,
164
+ rpc,
165
+ onSubmit,
166
+ }) => {
167
+ const block = useMemo(() => parseFormBlock(rawJson), [rawJson]);
168
+
169
+ return (
170
+ <PfrRpcProvider rpc={rpc}>
171
+ {block.error ? (
172
+ <FormErrorBox
173
+ block={block}
174
+ blockNumber={blockNumber}
175
+ theme={theme}
176
+ onSubmit={onSubmit}
177
+ />
178
+ ) : block.form ? (
179
+ <PfrFormView
180
+ form={block.form}
181
+ theme={theme}
182
+ agentId={agentId}
183
+ onSubmit={onSubmit}
184
+ />
185
+ ) : null}
186
+ </PfrRpcProvider>
187
+ );
188
+ };
189
+
190
+ export const MessageContainer: React.FC<MessageContainerProps> = ({ text, theme, agentId, rpc, onSubmit }) => {
191
+ const blocks = parseMessageContent(text);
192
+
193
+ return (
194
+ <PfrRpcProvider rpc={rpc}>
195
+ <View style={styles.container} testID="assistant-message">
196
+ {blocks.map((block, idx) => {
197
+ if (block.type === "text" && block.content) {
198
+ return (
199
+ <BlockErrorBoundary key={idx} fallbackText={block.content}>
200
+ <HostMarkdown
201
+ text={block.content}
202
+ theme={theme}
203
+ />
204
+ </BlockErrorBoundary>
205
+ );
206
+ }
207
+
208
+ if (block.type === "form") {
209
+ if (block.error) {
210
+ return (
211
+ <BlockErrorBoundary key={idx} fallbackText={block.rawJson ?? ""}>
212
+ <FormErrorBox
213
+ block={block}
214
+ blockNumber={blocks.slice(0, idx).filter((b) => b.type === "form").length + 1}
215
+ theme={theme}
216
+ onSubmit={onSubmit}
217
+ />
218
+ </BlockErrorBoundary>
219
+ );
220
+ }
221
+
222
+ if (block.form) {
223
+ return (
224
+ <BlockErrorBoundary key={idx} fallbackText={block.rawJson ?? ""}>
225
+ <PfrFormView
226
+ form={block.form}
227
+ theme={theme}
228
+ agentId={agentId}
229
+ onSubmit={onSubmit}
230
+ />
231
+ </BlockErrorBoundary>
232
+ );
233
+ }
234
+ }
235
+
236
+ return null;
237
+ })}
238
+ </View>
239
+ </PfrRpcProvider>
240
+ );
241
+ };
242
+
243
+ const styles = StyleSheet.create({
244
+ container: {
245
+ gap: 8,
246
+ width: "100%",
247
+ ...(Platform.OS === "web" ? { userSelect: "text" as const } : {}),
248
+ },
249
+ errorBox: {
250
+ borderWidth: 1,
251
+ borderRadius: 6,
252
+ padding: 12,
253
+ gap: 6,
254
+ marginVertical: 4,
255
+ },
256
+ errorTitle: {
257
+ fontSize: 13,
258
+ fontWeight: "600",
259
+ },
260
+ errorDetail: {
261
+ fontSize: 12,
262
+ },
263
+ fixButton: {
264
+ borderRadius: 6,
265
+ paddingHorizontal: 16,
266
+ paddingVertical: 8,
267
+ alignItems: "center",
268
+ justifyContent: "center",
269
+ },
270
+ fixButtonText: {
271
+ fontSize: 13,
272
+ fontWeight: "600",
273
+ },
274
+ sentText: {
275
+ fontSize: 12,
276
+ fontWeight: "600",
277
+ },
278
+ rawJsonBox: {
279
+ padding: 8,
280
+ borderRadius: 4,
281
+ },
282
+ rawJsonText: {
283
+ fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }),
284
+ fontSize: 11,
285
+ },
286
+ });
@@ -0,0 +1,24 @@
1
+ import type { PluginHostProps } from "@getpaseo/plugin/client";
2
+ import * as hostUi from "@getpaseo/plugin/client/ui";
3
+ import React from "react";
4
+ import { MarkdownText } from "./markdown";
5
+
6
+ type HostMarkdownComponent = React.ComponentType<{ children: string }>;
7
+
8
+ // Newer hosts export `Markdown` from the plugin UI module; it renders with the host's own
9
+ // typography, links, code highlighting and selection. Older hosts lack it, so fall back to
10
+ // the built-in mini renderer. Look it up per render so a missing export never throws.
11
+ function getHostMarkdown(): HostMarkdownComponent | undefined {
12
+ const candidate = (hostUi as { Markdown?: unknown }).Markdown;
13
+ return typeof candidate === "function" || (typeof candidate === "object" && candidate !== null)
14
+ ? (candidate as HostMarkdownComponent)
15
+ : undefined;
16
+ }
17
+
18
+ export function HostMarkdown({
19
+ text,
20
+ theme,
21
+ }: Pick<PluginHostProps, "theme"> & { text: string }) {
22
+ const Host = getHostMarkdown();
23
+ return Host ? <Host>{text}</Host> : <MarkdownText text={text} theme={theme} />;
24
+ }