refactor(plugin): consolidate the client UI entrypoint

This commit is contained in:
Mohamed Boudra
2026-10-20 08:25:22 +02:00
parent cd102d08b6
commit adda1a6322
26 changed files with 283 additions and 181 deletions
+9 -9
View File
@@ -240,7 +240,7 @@ Do not put any other code modules in the plugin root.
Shared files import contract helpers and types from `@getpaseo/plugin`. Server handler files import
`PluginHandlerContext` from `@getpaseo/plugin/server`. Client files import Paseo UI from
`@getpaseo/plugin/client/react-native`. Its `Icon` draws a Lucide name from the client's installed
`@getpaseo/plugin/client/ui`. Its `Icon` draws a Lucide name from the client's installed
icon set, or any `PluginIcon` through its `icon` prop; an unknown name or unsafe SVG renders nothing
so it cannot break the plugin surface.
Its controlled modal keeps presentation metadata on `<Modal title="…" icon={…}>` and body UI in
@@ -257,14 +257,14 @@ Classify every SDK export before adding it. All client entry points and implemen
Zod schemas, and functions that run in both runtimes. A type-only import is still an architectural
dependency; shared types must not refer to React components, hooks, Node APIs, or server contexts.
| Entry | Owns | May depend on |
| ---------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/react-native`, `/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
| Entry | Owns | May depend on |
| ------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------- |
| `@getpaseo/plugin` | Shared data, schemas, RPC/settings definitions, runtime-neutral helpers | Shared code only |
| `@getpaseo/plugin/server` | Server contexts, lifecycle contracts, and CLI process launch | Shared and server code |
| `@getpaseo/plugin/server/provider`, `/server/acp` | Server provider contracts and adapters | Shared and server code |
| `@getpaseo/plugin/client` | Client contribution contexts, hooks, navigation, and UI contribution types | Shared and client code |
| `@getpaseo/plugin/client/ui` | Host-provided UI components | Shared and client code |
| `@getpaseo/plugin/client/host` | App-owned rendering integration; not a plugin-author entry | Shared and client code |
Server code imports shared helpers from the root and server capabilities from `/server`. Client
code imports shared helpers from the root and client capabilities from `/client`. Neither runtime
@@ -9,7 +9,7 @@ import { connectNewWorkspaceDaemonClient } from "../support/helpers/new-workspac
const PLUGIN_ID = "plugin-host-ui-e2e";
const PLUGIN_SOURCE = `import { usePaseo } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -1,6 +1,7 @@
import { test } from "../support/fixtures";
import {
withTimelinePlugin,
viewInlineThinkingExample,
requestPluginTimeline,
interactWithStreamingCard,
expectWholeCompletedCard,
@@ -33,3 +34,9 @@ test("Overview preserves both consecutive tool plugin cards", async ({ page }, i
await expectBothConsecutiveTools(page);
});
});
test("inline-thinking example renders a streamed thought through host UI", async ({
page,
}, info) => {
await viewInlineThinkingExample(page, info);
});
@@ -36,7 +36,7 @@ function pluginClientSource(input: {
}): string {
return `import React, { useRef } from "react";
import { Pressable, Text, View } from "react-native";
import { Icon } from "@getpaseo/plugin/client/react-native";
import { Icon } from "@getpaseo/plugin/client/ui";
import { useAgent, useWorkspace, openExternalUrl } from "@getpaseo/plugin/client";
import { ExternalLink } from "@getpaseo/plugin/client/ui";
import { recordComposerOpen } from "./shared/rpc";
@@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, type Page, type TestInfo } from "@playwright/test";
import { pluginRequirements } from "./plugin-fixture";
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { openAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
@@ -274,3 +274,40 @@ export async function withPopoverTimelinePlugin(run: () => Promise<void>): Promi
run,
});
}
const THOUGHT =
"Need to find the scroll container, the layout effect that watches for new messages, and any gesture handler that might fight with programmatic scrolling. Probably a ref on the FlatList plus a near-bottom threshold.";
/** Exercises the maintained inline-thinking renderer against real streamed reasoning. */
export async function viewInlineThinkingExample(page: Page, info: TestInfo): Promise<void> {
info.setTimeout(120_000);
const example = await copyPluginExample("inline-thinking");
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
const agent = await seedMockAgentWorkspace({
repoPrefix: "inline-thinking-",
title: "Inline thinking example",
model: "one-minute-stream",
});
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(example.directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await requestPluginTimeline(agent);
const thought = page.getByText(/^Need to find the scroll container/);
await expect(thought).toBeVisible({ timeout: 60_000 });
await expect(thought).not.toHaveText(THOUGHT);
await info.attach("inline-thinking-streaming", {
body: await page.screenshot({ path: info.outputPath("inline-thinking-streaming.png") }),
contentType: "image/png",
});
await expect(thought).toHaveText(THOUGHT, { timeout: 30_000 });
} finally {
await client.removePlugin("inline-thinking");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled ?? false });
await client.close();
await agent.cleanup();
await example.cleanup();
}
}
+5 -5
View File
@@ -646,11 +646,11 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("must return a cleanup function");
});
it("provides the host Icon component through @getpaseo/plugin/client/react-native", () => {
it("provides the host Icon component through @getpaseo/plugin/client/ui", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { Icon } = require("@getpaseo/plugin/client/react-native");
const { Icon } = require("@getpaseo/plugin/client/ui");
const module = { exports: {} };
module.exports.default = function(plugin) {
plugin.addScreen({ id: "main", title: "Main", Component: function Surface() {
@@ -668,14 +668,14 @@ describe("evaluatePluginClientBundle", () => {
expect(element).toMatchObject({ props: { size: 18, color: "#123456" } });
});
it("provides Paseo UI through @getpaseo/plugin/client/react-native", () => {
it("provides Paseo UI through @getpaseo/plugin/client/ui", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { Icon, Modal, useToast } = require("@getpaseo/plugin/client/react-native");
const { Icon, Modal, useToast, SettingsSection, SidebarRow, StreamedText } = require("@getpaseo/plugin/client/ui");
const module = { exports: {} };
module.exports.default = function(plugin) {
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function") {
if (typeof Icon !== "function" || typeof Modal !== "function" || typeof Modal.Content !== "function" || typeof useToast !== "function" || typeof SettingsSection !== "function" || typeof SidebarRow !== "function" || typeof StreamedText !== "function") {
throw new Error("React Native plugin UI is incomplete");
}
plugin.addSurface("main", function Surface() { return null; });
+2 -4
View File
@@ -34,7 +34,6 @@ import {
import type { EvaluatedPlugin, PluginSidebarSection } from "./types";
import type { ComponentType } from "react";
import { parsePluginIcon, parseSerializablePluginIcon } from "./icons";
import { pluginReactNativeRuntime } from "./react-native/runtime";
import { parsePluginThemeContribution } from "./themes";
const CONTRIBUTION_ID = /^[a-z][a-z0-9-]*$/;
@@ -478,6 +477,8 @@ export function runPluginClientBundle(
},
};
const runtimeRequire = (name: string): unknown => {
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
if (name === "@getpaseo/plugin/client/react-native") return pluginUiRuntime;
if (name === "@getpaseo/plugin/client/ui") return pluginUiRuntime;
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
@@ -496,9 +497,6 @@ export function runPluginClientBundle(
runtime.hosts.getSnapshot,
),
};
if (name === "@getpaseo/plugin/client/react-native") {
return pluginReactNativeRuntime;
}
if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`);
+1 -1
View File
@@ -8,7 +8,7 @@ import {
type PluginSvgIcon,
} from "@getpaseo/plugin";
import type { PluginIcon } from "@getpaseo/plugin/client";
import type { IconProps } from "@getpaseo/plugin/client/react-native";
import type { IconProps } from "@getpaseo/plugin/client/ui";
import { SvgIcon } from "@/components/provider-icons";
/** Host surfaces may thin a Lucide stroke; SVG and plugin components ignore it. */
@@ -1,9 +1,5 @@
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import type {
ModalComponent,
ModalContentProps,
ModalProps,
} from "@getpaseo/plugin/client/react-native";
import type { ModalComponent, ModalContentProps, ModalProps } from "@getpaseo/plugin/client/ui";
import { usePluginRuntimeContextBridge } from "@getpaseo/plugin/client/host";
import { createContext, useCallback, useContext, useMemo, useRef, type ReactNode } from "react";
import { ToastApiProvider, useToast as useAppToast } from "@/contexts/toast-api-context";
@@ -1,18 +0,0 @@
import { Icon } from "../icons";
import { Modal } from "./modal";
import { ScrollView, FlatList } from "./scroll-view";
import { TextInput } from "./text-input";
import { copyText } from "./clipboard";
import { useToast } from "./toast";
import { useRevealedText } from "@/hooks/use-revealed-text";
export const pluginReactNativeRuntime = {
Icon,
Modal,
ScrollView,
FlatList,
TextInput,
copyText,
useRevealedText,
useToast,
};
@@ -0,0 +1,22 @@
import type { StreamedTextProps } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useRevealedText } from "@/hooks/use-revealed-text";
export function StreamedText({ text, phase, style, ...props }: StreamedTextProps) {
const revealed = useRevealedText(text, phase);
return (
<Text {...props} style={[styles.text, style]}>
{revealed}
</Text>
);
}
const styles = StyleSheet.create((theme) => ({
text: {
color: theme.colors.foreground,
fontFamily: theme.fontFamily.ui,
fontSize: theme.fontSize.content,
lineHeight: Math.round(theme.fontSize.content * 1.4),
},
}));
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/client/react-native";
import type { ToastApi, ToastOptions } from "@getpaseo/plugin/client/ui";
import { useToast as useAppToast } from "@/contexts/toast-api-context";
export function useToast(): ToastApi {
+11 -1
View File
@@ -1,5 +1,4 @@
export {
SettingsGroup,
SettingsSection,
SettingsCard,
SettingsRow,
@@ -16,3 +15,14 @@ export { SidebarRow, SidebarSeparator } from "@/plugins/sidebar-items/kit";
export { DocumentFrame } from "@/file-panels/document-frame";
export { FilePanelLoading as Loading } from "@/file-panels/loading";
// COMPAT(pluginSettingsGroup): added in v0.13.0, remove after 2027-04-20
export { SettingsGroup } from "@/components/settings";
export { Icon } from "../icons";
export { Modal } from "./modal";
export { ScrollView, FlatList } from "./scroll-view";
export { TextInput } from "./text-input";
export { copyText } from "./clipboard";
export { useToast } from "./toast";
export { StreamedText } from "./streamed-text";
@@ -196,7 +196,7 @@ export async function inspectConfig(
path.join(directory, "client", "main.tsx"),
`import React from "react";
import { Text } from "react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
import { type PluginAgentPanelProps, type PluginClientContext, type PluginSurfaceProps, useAgent, usePaseo, useWorkspace } from "@getpaseo/plugin/client";
import { inspect } from "../shared/inspect";
+17 -3
View File
@@ -15,9 +15,14 @@ const entries = {
"./server/acp": "server",
"./client": "client",
"./client/host": "client",
"./client/react-native": "client",
"./client/ui": "client",
} as const;
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
// Compatibility entrypoints inherit the boundary of their one re-export target.
const compatibilityEntries = Object.keys(manifest.exports).filter((entry) => {
const file = path.resolve(sourceDirectory, "..", manifest.exports[entry].source);
return readFileSync(file, "utf8").startsWith("// COMPAT(");
});
const uiDependency = /^(react(?:-dom|-native)?|use-sync-external-store)(\/|$)/;
const serverModule = /^server\//;
const clientModule = /^client\//;
@@ -70,8 +75,17 @@ describe("plugin SDK import boundaries", () => {
});
it("classifies every published entry", () => {
const manifest = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
expect(Object.keys(manifest.exports).sort()).toEqual(Object.keys(entries).sort());
expect(Object.keys(manifest.exports).sort()).toEqual(
[...Object.keys(entries), ...compatibilityEntries].sort(),
);
});
it.each(compatibilityEntries)("%s only re-exports the UI entry", (entry) => {
const source = readFileSync(
path.resolve(sourceDirectory, "..", manifest.exports[entry].source),
"utf8",
);
expect(source.replace(/\/\/[^\n]*|\/\*[^]*?\*\//g, "").trim()).toBe('export * from "./ui.js";');
});
it.each(Object.entries(entries))(
+18
View File
@@ -1,4 +1,13 @@
import { describe, expect, expectTypeOf, it } from "vitest";
import type { ComponentProps } from "react";
import type {
StreamedText,
StreamedTextProps,
Modal,
ModalProps,
SettingsSection,
SettingsSectionProps,
} from "./ui.js";
import { z } from "zod";
import { defineAttachmentSource, defineRpc, type RpcInput, type RpcOutput } from "../index.js";
import { callPluginRpc, searchPluginAttachments } from "./host.js";
@@ -107,3 +116,12 @@ describe("plugin attachment sources", () => {
).rejects.toBeInstanceOf(z.ZodError);
});
});
describe("plugin UI contracts", () => {
it("exposes presentation capabilities from the UI entry", () => {
expectTypeOf<ComponentProps<typeof StreamedText>>().toEqualTypeOf<StreamedTextProps>();
expectTypeOf<ComponentProps<typeof Modal>>().toEqualTypeOf<ModalProps>();
expectTypeOf<ComponentProps<typeof SettingsSection>>().toEqualTypeOf<SettingsSectionProps>();
expectTypeOf<StreamedTextProps["phase"]>().toEqualTypeOf<"streaming" | "complete">();
});
});
+3 -81
View File
@@ -1,81 +1,3 @@
import type {
ComponentType,
FunctionComponent,
ReactNode,
ForwardRefExoticComponent,
RefAttributes,
ReactElement,
Ref,
} from "react";
import type {
StyleProp,
ViewStyle,
ScrollView as NativeScrollView,
ScrollViewProps,
FlatList as NativeFlatList,
FlatListProps,
TextInput as NativeTextInput,
TextInputProps,
} from "react-native";
import type { PluginIconProps } from "../icons.js";
import type { PluginIcon } from "./contracts.js";
export interface ModalProps {
title: string;
icon?: ReactNode;
open: boolean;
onOpenChange(open: boolean): void;
children: ReactNode;
}
export interface ModalContentProps {
children: ReactNode;
/** Paint the full body below the header. */
style?: StyleProp<ViewStyle>;
/** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */
contentContainerStyle?: StyleProp<ViewStyle>;
/** Default true. Set false for a bounded body with your own ScrollView or FlatList. */
scrollable?: boolean;
}
export interface ModalComponent extends FunctionComponent<ModalProps> {
Content: ComponentType<ModalContentProps>;
}
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
export interface ToastOptions {
variant?: ToastVariant;
durationMs?: number;
}
export interface ToastApi {
show(message: string, options?: ToastOptions): void;
error(message: string): void;
}
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
export type IconProps = Partial<PluginIconProps> &
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
export declare const Icon: ComponentType<IconProps>;
export declare const Modal: ModalComponent;
export declare function useToast(): ToastApi;
export declare function useRevealedText(text: string, phase: "streaming" | "complete"): string;
export type { PluginIconProps } from "../icons.js";
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
export declare const ScrollView: ForwardRefExoticComponent<
ScrollViewProps & RefAttributes<NativeScrollView>
>;
export declare function FlatList<Item>(
props: FlatListProps<Item> & { ref?: Ref<NativeFlatList<Item>> },
): ReactElement;
/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */
export declare function copyText(text: string): Promise<void>;
/** Native input focus integrated with modal keyboard positioning. */
export declare const TextInput: ForwardRefExoticComponent<
TextInputProps & RefAttributes<NativeTextInput>
>;
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
/** @deprecated Import Paseo UI from @getpaseo/plugin/client/ui. */
export * from "./ui.js";
+88 -1
View File
@@ -1,6 +1,91 @@
import type { ComponentType, ReactNode, Ref } from "react";
import type {
ComponentType,
FunctionComponent,
ReactNode,
ForwardRefExoticComponent,
RefAttributes,
ReactElement,
Ref,
} from "react";
import type {
StyleProp,
ViewStyle,
ScrollView as NativeScrollView,
ScrollViewProps,
FlatList as NativeFlatList,
FlatListProps,
TextInput as NativeTextInput,
TextInputProps,
TextProps,
} from "react-native";
import type { PluginIconProps } from "../icons.js";
import type { PluginIcon } from "./contracts.js";
export interface ModalProps {
title: string;
icon?: ReactNode;
open: boolean;
onOpenChange(open: boolean): void;
children: ReactNode;
}
export interface ModalContentProps {
children: ReactNode;
/** Paint the full body below the header. */
style?: StyleProp<ViewStyle>;
/** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */
contentContainerStyle?: StyleProp<ViewStyle>;
/** Default true. Set false for a bounded body with your own ScrollView or FlatList. */
scrollable?: boolean;
}
export interface ModalComponent extends FunctionComponent<ModalProps> {
Content: ComponentType<ModalContentProps>;
}
export type ToastVariant = "default" | "info" | "success" | "warning" | "error";
export interface ToastOptions {
variant?: ToastVariant;
durationMs?: number;
}
export interface ToastApi {
show(message: string, options?: ToastOptions): void;
error(message: string): void;
}
/** Renders a Lucide icon by `name`, or any plugin icon by `icon`. An unusable icon renders nothing. */
export type IconProps = Partial<PluginIconProps> &
({ name: string; icon?: never } | { icon: PluginIcon; name?: never });
export declare const Icon: ComponentType<IconProps>;
export declare const Modal: ModalComponent;
export declare function useToast(): ToastApi;
export interface StreamedTextProps extends Omit<TextProps, "children"> {
text: string;
phase: "streaming" | "complete";
}
/** Plain assistant text with host-owned streaming pacing and typography. */
export declare const StreamedText: ComponentType<StreamedTextProps>;
export type { PluginIconProps } from "../icons.js";
/** React Native scrolling with the host's sheet gestures when rendered inside a sheet. */
export declare const ScrollView: ForwardRefExoticComponent<
ScrollViewProps & RefAttributes<NativeScrollView>
>;
export declare function FlatList<Item>(
props: FlatListProps<Item> & { ref?: Ref<NativeFlatList<Item>> },
): ReactElement;
/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */
export declare function copyText(text: string): Promise<void>;
/** Native input focus integrated with modal keyboard positioning. */
export declare const TextInput: ForwardRefExoticComponent<
TextInputProps & RefAttributes<NativeTextInput>
>;
export interface SettingsSectionProps {
title: string;
info?: ReactNode;
@@ -45,6 +130,8 @@ export interface SettingsActionProps extends SettingsRowProps {
onPress(): void;
disabled?: boolean;
}
// COMPAT(pluginSettingsGroup): added in v0.13.0, remove after 2027-04-20
/** @deprecated Use SettingsSection for plugin settings headings. */
export declare const SettingsGroup: ComponentType<SettingsSectionProps>;
export declare const SettingsSection: ComponentType<SettingsSectionProps>;
export declare const SettingsCard: ComponentType<{ children: ReactNode; testID?: string }>;
@@ -2,6 +2,7 @@
export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [
"@getpaseo/plugin/client",
"@getpaseo/plugin/client/ui",
// COMPAT(pluginReactNativeEntrypoint): added in v0.13.0, remove after 2027-04-20
"@getpaseo/plugin/client/react-native",
] as const;
@@ -1,7 +1,6 @@
import type { PluginTimelineItemProps } from "@getpaseo/plugin/client";
import { useRevealedText } from "@getpaseo/plugin/client/react-native";
import { StreamedText } from "@getpaseo/plugin/client/ui";
import { useMemo } from "react";
import { Text } from "react-native";
import { z } from "zod";
export const inlineThinkingSchema = z.object({
@@ -12,10 +11,9 @@ export const inlineThinkingSchema = z.object({
type InlineThinkingData = z.output<typeof inlineThinkingSchema>;
export function InlineThinking({ item, theme }: PluginTimelineItemProps<InlineThinkingData>) {
const text = useRevealedText(item.data.text, item.data.phase);
const style = useMemo(
() => ({ color: theme.colors.foregroundMuted }),
[theme.colors.foregroundMuted],
);
return <Text style={style}>{text}</Text>;
return <StreamedText text={item.data.text} phase={item.data.phase} style={style} />;
}
@@ -1,6 +1,6 @@
{
"id": "inline-thinking",
"requirements": {
"paseo": ">=0.8.0"
"paseo": ">=0.13.0"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {
TextInput,
copyText,
useToast,
} from "@getpaseo/plugin/client/react-native";
} from "@getpaseo/plugin/client/ui";
import { useCallback, useMemo, useRef, useState } from "react";
import {
Pressable,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "modal-ui-example",
"requirements": {
"paseo": ">=0.8.0"
"paseo": ">=0.13.0"
},
"description": "Interactive modal forms, scrolling lists, and clipboard examples"
}
+18 -8
View File
@@ -134,21 +134,22 @@ show chevrons. See [buttons](./reference.md#button-descriptor) for menus, popove
Move hooks (`usePaseo`, `useRpc`, `useSettings`, `useAgent`, `useWorkspace`) and client contribution
types from `@getpaseo/plugin` to `@getpaseo/plugin/client`. Move `Icon` to
`@getpaseo/plugin/client/react-native`. Import server contexts and lifecycle contracts from
`@getpaseo/plugin/client/ui`. Import server contexts and lifecycle contracts from
`@getpaseo/plugin/server`. Shared helpers (`defineRpc`, `defineSettings`, `defineAttachmentSource`),
schemas, and plain data types stay on the root. These rules include type imports. See
[Runtime modules](reference#runtime-modules) for the complete contract.
Move the remaining SDK subpaths under their runtime owner:
| Old entry | 0.8 entry |
| ------------------------------- | -------------------------------------- |
| `@getpaseo/plugin/react-native` | `@getpaseo/plugin/client/react-native` |
| `@getpaseo/plugin/ui` | `@getpaseo/plugin/client/ui` |
| `@getpaseo/plugin/provider` | `@getpaseo/plugin/server/provider` |
| `@getpaseo/plugin/acp` | `@getpaseo/plugin/server/acp` |
| Old entry | Current entry |
| -------------------------------------- | ---------------------------------- |
| `@getpaseo/plugin/react-native` | `@getpaseo/plugin/client/ui` |
| `@getpaseo/plugin/client/react-native` | `@getpaseo/plugin/client/ui` |
| `@getpaseo/plugin/ui` | `@getpaseo/plugin/client/ui` |
| `@getpaseo/plugin/provider` | `@getpaseo/plugin/server/provider` |
| `@getpaseo/plugin/acp` | `@getpaseo/plugin/server/acp` |
The old entries and the pre-0.8 `@paseo/plugin` scope are removed. `/client/host` is private to
The pre-0.8 entries and the old `@paseo/plugin` scope are removed. `/client/host` is private to
Paseo's app integration and is never a plugin-author import.
The client entry imports only `client/`, `shared/`, and client-safe packages. The server entry imports
@@ -156,6 +157,15 @@ only `server/`, `shared/`, and server-safe packages. A `node:` import in the cli
reachable from it is a compile error. Never import a component into the server entry merely to wire
its registration; that registration belongs in the client entry.
### UI imports in v0.13
Use `/client/ui` for all Paseo UI components and UI hooks. The previous entry remains an
undocumented compatibility alias until 2027-04-20.
For paced timeline text, replace `useRevealedText(text, phase)` and the surrounding `Text` with
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.
Use `SettingsSection` for plugin settings headings; `SettingsGroup` is deprecated.
## 5. Recognize half-migration errors
| Compiler or load error | Meaning and fix |
+31 -31
View File
@@ -138,24 +138,23 @@ only shared code: no Node, React, runtime-specific SDK entries, or runtime-speci
The SDK root (`@getpaseo/plugin`) contains shared data, schemas, and runtime-neutral helpers only.
Import client contexts and hooks from `/client`, server contexts and lifecycle contracts from
`/server`, and UI from `/client/react-native` or `/client/ui`. These rules include type imports and transitive
`/server`, and UI from `/client/ui`. These rules include type imports and transitive
dependencies. `/client/host` is private to the app host; plugins cannot import it.
### Client runtime
Paseo provides these modules to client code:
| Module | Use it for |
| -------------------------------------- | ------------------------------------------------------------------------------------------------- |
| `@getpaseo/plugin` | Shared data, `defineRpc`, `defineSettings`, `defineAttachmentSource`, `RpcInput`, and `RpcOutput` |
| `@getpaseo/plugin/client/ui` | Named, composable settings components |
| `@getpaseo/plugin/client/react-native` | Paseo UI components and UI hooks |
| `@getpaseo/plugin/client` | Client contribution contexts, `usePaseo`, `useRpc`, `useSettings`, and data hooks |
| `@tanstack/react-query` | Request state and caching |
| `react` | Components and hooks |
| `react/jsx-runtime` | Compiled JSX |
| `react-native` | Cross-platform UI |
| `zod` | Shared schemas |
| Module | Use it for |
| ---------------------------- | ------------------------------------------------------------------------------------------------- |
| `@getpaseo/plugin` | Shared data, `defineRpc`, `defineSettings`, `defineAttachmentSource`, `RpcInput`, and `RpcOutput` |
| `@getpaseo/plugin/client/ui` | Paseo UI components and UI hooks |
| `@getpaseo/plugin/client` | Client contribution contexts, `usePaseo`, `useRpc`, `useSettings`, and data hooks |
| `@tanstack/react-query` | Request state and caching |
| `react` | Components and hooks |
| `react/jsx-runtime` | Compiled JSX |
| `react-native` | Cross-platform UI |
| `zod` | Shared schemas |
The host owns its paired React and renderer versions. The SDK's React peer range permits patch
versions for tooling and Node consumers; it does not change the app's pinned React version or
@@ -1072,12 +1071,12 @@ function hideAlerts() {
## Host UI
Import Paseo-owned UI from `@getpaseo/plugin/client/react-native` in client code. This example
Import Paseo-owned UI from `@getpaseo/plugin/client/ui` in client code. This example
opens a controlled modal, renders a host icon, and confirms the action with a toast:
```tsx
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/react-native";
import { Icon, Modal, useToast } from "@getpaseo/plugin/client/ui";
import { useState } from "react";
import { Pressable, Text, View } from "react-native";
@@ -1156,7 +1155,7 @@ Modal children keep the plugin runtime context. `usePaseo`, `useRpc`, `useWorksp
### Scrolling
Import `ScrollView` and `FlatList` from `@getpaseo/plugin/client/react-native` when content can appear in a
Import `ScrollView` and `FlatList` from `@getpaseo/plugin/client/ui` when content can appear in a
Paseo modal. They accept React Native props and refs and integrate with the sheet's gestures. Outside
a sheet they use ordinary React Native scrolling. Do not import bottom-sheet libraries directly.
@@ -1170,7 +1169,7 @@ container without changing these gestures. Expand the sheet before using list me
`scrollToEnd`; the sheet locks list offsets below its largest height.
```tsx
import { FlatList, Modal } from "@getpaseo/plugin/client/react-native";
import { FlatList, Modal } from "@getpaseo/plugin/client/ui";
import { Text } from "react-native";
// Inside your controlled Modal:
@@ -1200,7 +1199,7 @@ user action and await it before reporting success. It rejects if the platform de
clipboard is unavailable; browser permissions and secure-context requirements still apply.
```tsx
import { copyText, useToast } from "@getpaseo/plugin/client/react-native";
import { copyText, useToast } from "@getpaseo/plugin/client/ui";
// Inside your component:
const toast = useToast();
@@ -1215,7 +1214,7 @@ async function copyResult() {
```
Programmatic copying and native text selection are separate interactions. Use `<Text selectable>`
for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/client/react-native` for modal forms. It accepts React Native
for long-press selection and OS Copy. Import `TextInput` from `@getpaseo/plugin/client/ui` for modal forms. It accepts React Native
input props and refs, supports OS Paste, and registers focus with the native sheet so the keyboard
can raise the form. Outside a sheet it uses the ordinary input. A plain React Native input supports
Paste too, but does not register focus with the sheet; the keyboard can cover it. No clipboard read
@@ -1310,7 +1309,7 @@ client.addWorkspacePanel({ id: "review", title: "Review", icon: { svg: reviewIco
#### Render an icon
`Icon` from `@getpaseo/plugin/client/react-native` draws an icon in your own UI. Plugin bundles do
`Icon` from `@getpaseo/plugin/client/ui` draws an icon in your own UI. Plugin bundles do
not import `lucide-react-native` or `react-native-svg`.
| Prop | Type | Required | Behavior |
@@ -1381,9 +1380,10 @@ source item's output.
Renderers receive `agentId`, `item`, `timestamp`, `theme`, `host`, and `layout`. Paseo validates
`item.data` with the registered schema before rendering. Keep transformers synchronous and
deterministic. Paseo memoizes results by source-item reference and derives replacement identity from
the source row, so updates to one streaming item do not remount its renderer. Use the exported
`useRevealedText(text, phase)` hook when a renderer should pace streaming text like Paseo's built-in
assistant rows.
the source row, so updates to one streaming item do not remount its renderer. Render `<StreamedText text={item.data.text} phase={item.data.phase} />` from
`@getpaseo/plugin/client/ui` for plain text paced like Paseo's assistant rows. It accepts React Native
`Text` props (including `style`) and supplies assistant typography and foreground color by default.
Completion displays the full text immediately; the host owns pacing while streaming. Requires Paseo 0.13 or later.
Chat find (Ctrl or Cmd+F) finds and highlights text in replacement rows when the query also
matches the original message. The host searches the original prompt text or the assistant reply
@@ -1540,15 +1540,15 @@ export function DisplaySettings() {
}
```
| Component | Props and behavior |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `SettingsGroup`, `SettingsSection` | Required `title`, `children`; optional `info` tooltip, `trailing` content, `testID`. Own section spacing and headings. |
| `SettingsCard` | `children`, optional `testID`. Owns the card surface and dividers between direct children. Give mapped rows stable React keys. |
| `SettingsRow` | Required `label`; optional `hint`, `error`, `children`, `testID`. Wrap any custom control or content. |
| `SettingsSwitch` | Row props plus required `value: boolean`, `onValueChange`; optional `disabled`. |
| `SettingsSelect` | Row props plus required string `value`, `options: { label, value }[]`, `onValueChange`; optional `disabled`. Uses Paseo's adaptive menu. |
| `SettingsInput` | Row props plus required `onChangeText`; optional `initialValue`, `placeholder`, `disabled`, `secureTextEntry`, `ref`. |
| `SettingsAction` | Row props plus required `actionLabel`, `onPress`; optional `disabled`. |
| Component | Props and behavior |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `SettingsSection` | Required `title`, `children`; optional `info` tooltip, `trailing` content, `testID`. Own section spacing and headings. |
| `SettingsCard` | `children`, optional `testID`. Owns the card surface and dividers between direct children. Give mapped rows stable React keys. |
| `SettingsRow` | Required `label`; optional `hint`, `error`, `children`, `testID`. Wrap any custom control or content. |
| `SettingsSwitch` | Row props plus required `value: boolean`, `onValueChange`; optional `disabled`. |
| `SettingsSelect` | Row props plus required string `value`, `options: { label, value }[]`, `onValueChange`; optional `disabled`. Uses Paseo's adaptive menu. |
| `SettingsInput` | Row props plus required `onChangeText`; optional `initialValue`, `placeholder`, `disabled`, `secureTextEntry`, `ref`. |
| `SettingsAction` | Row props plus required `actionLabel`, `onPress`; optional `disabled`. |
`SettingsInput` owns in-progress text. `initialValue` seeds it when mounted. Its ref exposes
`focus()`, `blur()`, `getText()`, and `replaceText(text)` for explicit programmatic changes.
+2 -2
View File
@@ -261,7 +261,7 @@ Icons are Lucide icon names, inline SVG (`{ svg }`), or components; see [Icons](
Before writing imports, classify each module as shared, client, or server. Follow the
[SDK import boundaries](https://paseo.sh/docs/plugins/reference.md#runtime-modules), including
transitive and type dependencies. The root is shared-only; hooks and client contexts belong to
`@getpaseo/plugin/client`, server contexts to `/server`, and host UI to `/client/react-native` or `/client/ui`.
`@getpaseo/plugin/client`, server contexts to `/server`, and host UI to `/client/ui`.
Install dependencies locally for typechecking; Paseo supplies host runtime modules. JSX uses the
automatic runtime. Do not import `/client/host` from plugin code.
@@ -568,7 +568,7 @@ client.addTimelineRenderer({
});
```
Transformers run while the render model is built, on fetched history and on every live update, so `phase` is `"streaming"` for a loading thought or running tool call. Identity comes from the source item, so a streaming item keeps its mounted component; set an output `id` when one source explodes into several items. Transformers must be synchronous and deterministic, `data` must be JSON, and a transformer that throws is logged and skipped. Use `useRevealedText(text, phase)` from `@getpaseo/plugin/client/react-native` to pace streaming text. `plugin-examples/inline-thinking` replaces the thinking row with inline text; `plugin-examples/timeline-items` replaces a Pi todo tool call with a task card.
Transformers run while the render model is built, on fetched history and on every live update, so `phase` is `"streaming"` for a loading thought or running tool call. Identity comes from the source item, so a streaming item keeps its mounted component; set an output `id` when one source explodes into several items. Transformers must be synchronous and deterministic, `data` must be JSON, and a transformer that throws is logged and skipped. Render `<StreamedText text={text} phase={phase} />` from `@getpaseo/plugin/client/ui` for paced assistant text; use its `style` prop to customize text appearance. `plugin-examples/inline-thinking` replaces the thinking row with inline text; `plugin-examples/timeline-items` replaces a todo tool call with a task card.
## Append a timeline row from the daemon