diff --git a/docs/plugins.md b/docs/plugins.md index 765a84dac1..fc000f7b15 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 `` 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 diff --git a/packages/app/e2e/browser/plugin-host-ui.spec.ts b/packages/app/e2e/browser/plugin-host-ui.spec.ts index 1d952fe92f..75ea218a8f 100644 --- a/packages/app/e2e/browser/plugin-host-ui.spec.ts +++ b/packages/app/e2e/browser/plugin-host-ui.spec.ts @@ -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"; diff --git a/packages/app/e2e/browser/plugin-timeline.spec.ts b/packages/app/e2e/browser/plugin-timeline.spec.ts index a344611a4f..166e6b9881 100644 --- a/packages/app/e2e/browser/plugin-timeline.spec.ts +++ b/packages/app/e2e/browser/plugin-timeline.spec.ts @@ -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); +}); diff --git a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts index 8895f85acf..36f92429c7 100644 --- a/packages/app/e2e/browser/plugin-workspace-panels.spec.ts +++ b/packages/app/e2e/browser/plugin-workspace-panels.spec.ts @@ -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"; diff --git a/packages/app/e2e/support/helpers/plugin-timeline.ts b/packages/app/e2e/support/helpers/plugin-timeline.ts index 8b0f680e2d..145addc2ef 100644 --- a/packages/app/e2e/support/helpers/plugin-timeline.ts +++ b/packages/app/e2e/support/helpers/plugin-timeline.ts @@ -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): 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 { + 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(); + } +} diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index 40d79dd969..9d5ed28349 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -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; }); diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 39905203af..e54a2efd31 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -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`); diff --git a/packages/app/src/plugins/icons.ts b/packages/app/src/plugins/icons.ts index ba67d8fa35..aa2a5765c9 100644 --- a/packages/app/src/plugins/icons.ts +++ b/packages/app/src/plugins/icons.ts @@ -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. */ diff --git a/packages/app/src/plugins/react-native/modal.tsx b/packages/app/src/plugins/react-native/modal.tsx index b61cd925b1..18601149b5 100644 --- a/packages/app/src/plugins/react-native/modal.tsx +++ b/packages/app/src/plugins/react-native/modal.tsx @@ -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"; diff --git a/packages/app/src/plugins/react-native/runtime.ts b/packages/app/src/plugins/react-native/runtime.ts deleted file mode 100644 index bdf39910a4..0000000000 --- a/packages/app/src/plugins/react-native/runtime.ts +++ /dev/null @@ -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, -}; diff --git a/packages/app/src/plugins/react-native/streamed-text.tsx b/packages/app/src/plugins/react-native/streamed-text.tsx new file mode 100644 index 0000000000..cb85c2392f --- /dev/null +++ b/packages/app/src/plugins/react-native/streamed-text.tsx @@ -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 ( + + {revealed} + + ); +} + +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), + }, +})); diff --git a/packages/app/src/plugins/react-native/toast.ts b/packages/app/src/plugins/react-native/toast.ts index fd4d503799..44ce986cec 100644 --- a/packages/app/src/plugins/react-native/toast.ts +++ b/packages/app/src/plugins/react-native/toast.ts @@ -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 { diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index 77128b731a..a33add1d4f 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -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"; diff --git a/packages/cli/src/commands/plugin/scaffold.test.ts b/packages/cli/src/commands/plugin/scaffold.test.ts index a3350ecb8c..91a5c11d3e 100644 --- a/packages/cli/src/commands/plugin/scaffold.test.ts +++ b/packages/cli/src/commands/plugin/scaffold.test.ts @@ -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"; diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index d9198186d8..eb6850b05c 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -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))( diff --git a/packages/plugin/src/client/host.test.ts b/packages/plugin/src/client/host.test.ts index 894fe832a5..5b5333fcd1 100644 --- a/packages/plugin/src/client/host.test.ts +++ b/packages/plugin/src/client/host.test.ts @@ -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>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"streaming" | "complete">(); + }); +}); diff --git a/packages/plugin/src/client/react-native.ts b/packages/plugin/src/client/react-native.ts index f5862b31d1..e1eb92f17b 100644 --- a/packages/plugin/src/client/react-native.ts +++ b/packages/plugin/src/client/react-native.ts @@ -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; - /** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */ - contentContainerStyle?: StyleProp; - /** Default true. Set false for a bounded body with your own ScrollView or FlatList. */ - scrollable?: boolean; -} - -export interface ModalComponent extends FunctionComponent { - Content: ComponentType; -} - -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 & - ({ name: string; icon?: never } | { icon: PluginIcon; name?: never }); - -export declare const Icon: ComponentType; -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 ->; -export declare function FlatList( - props: FlatListProps & { ref?: Ref> }, -): ReactElement; -/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */ -export declare function copyText(text: string): Promise; - -/** Native input focus integrated with modal keyboard positioning. */ -export declare const TextInput: ForwardRefExoticComponent< - TextInputProps & RefAttributes ->; +// 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"; diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 186b706eb6..7bd8d6884b 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -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; + /** Overrides the default 24px padding and 16px gap. Safe-area clearance stays host-owned. */ + contentContainerStyle?: StyleProp; + /** Default true. Set false for a bounded body with your own ScrollView or FlatList. */ + scrollable?: boolean; +} + +export interface ModalComponent extends FunctionComponent { + Content: ComponentType; +} + +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 & + ({ name: string; icon?: never } | { icon: PluginIcon; name?: never }); + +export declare const Icon: ComponentType; +export declare const Modal: ModalComponent; +export declare function useToast(): ToastApi; +export interface StreamedTextProps extends Omit { + text: string; + phase: "streaming" | "complete"; +} +/** Plain assistant text with host-owned streaming pacing and typography. */ +export declare const StreamedText: ComponentType; + +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 +>; +export declare function FlatList( + props: FlatListProps & { ref?: Ref> }, +): ReactElement; +/** Copies text to this client's clipboard. Rejects when copying is unavailable or denied. */ +export declare function copyText(text: string): Promise; + +/** Native input focus integrated with modal keyboard positioning. */ +export declare const TextInput: ForwardRefExoticComponent< + TextInputProps & RefAttributes +>; + 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; export declare const SettingsSection: ComponentType; export declare const SettingsCard: ComponentType<{ children: ReactNode; testID?: string }>; diff --git a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts index 079f1c501d..d3931ef209 100644 --- a/packages/server/src/server/plugins/plugin-sdk-specifiers.ts +++ b/packages/server/src/server/plugins/plugin-sdk-specifiers.ts @@ -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; diff --git a/plugin-examples/inline-thinking/client/thinking.tsx b/plugin-examples/inline-thinking/client/thinking.tsx index 88bf4d6903..c480d5dacf 100644 --- a/plugin-examples/inline-thinking/client/thinking.tsx +++ b/plugin-examples/inline-thinking/client/thinking.tsx @@ -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; export function InlineThinking({ item, theme }: PluginTimelineItemProps) { - const text = useRevealedText(item.data.text, item.data.phase); const style = useMemo( () => ({ color: theme.colors.foregroundMuted }), [theme.colors.foregroundMuted], ); - return {text}; + return ; } diff --git a/plugin-examples/inline-thinking/paseo-plugin.json b/plugin-examples/inline-thinking/paseo-plugin.json index eade794f02..73446a5918 100644 --- a/plugin-examples/inline-thinking/paseo-plugin.json +++ b/plugin-examples/inline-thinking/paseo-plugin.json @@ -1,6 +1,6 @@ { "id": "inline-thinking", "requirements": { - "paseo": ">=0.8.0" + "paseo": ">=0.13.0" } } diff --git a/plugin-examples/modal-ui/client/examples.tsx b/plugin-examples/modal-ui/client/examples.tsx index bbd0c8c0e8..221716d3af 100644 --- a/plugin-examples/modal-ui/client/examples.tsx +++ b/plugin-examples/modal-ui/client/examples.tsx @@ -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, diff --git a/plugin-examples/modal-ui/paseo-plugin.json b/plugin-examples/modal-ui/paseo-plugin.json index 854a535736..c6a86fc2f5 100644 --- a/plugin-examples/modal-ui/paseo-plugin.json +++ b/plugin-examples/modal-ui/paseo-plugin.json @@ -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" } diff --git a/public-docs/plugins/migration.md b/public-docs/plugins/migration.md index eeeb0708fc..8d08343ec0 100644 --- a/public-docs/plugins/migration.md +++ b/public-docs/plugins/migration.md @@ -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 +`` from `/client/ui`. +Use `SettingsSection` for plugin settings headings; `SettingsGroup` is deprecated. + ## 5. Recognize half-migration errors | Compiler or load error | Meaning and fix | diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 65f441ec01..6ae4fddae4 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -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 @@ -1081,12 +1080,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"; @@ -1165,7 +1164,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. @@ -1179,7 +1178,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: @@ -1209,7 +1208,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(); @@ -1224,7 +1223,7 @@ async function copyResult() { ``` Programmatic copying and native text selection are separate interactions. Use `` -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 @@ -1319,7 +1318,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 | @@ -1390,9 +1389,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 `` 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 @@ -1549,15 +1549,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. diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index d19f9d56dc..028d869085 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -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 `` 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