From 9bce7dbd25a2b0c700ca83130e597b9c44bbd1dd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 21 Oct 2026 11:12:22 +0700 Subject: [PATCH] refactor(plugin): unify the client UI entrypoint (#7474) * fix(files): report document MIME types for binary files File reads now carry the document's MIME type for binary files, so a file panel can hand the bytes to a viewer that needs the real type (application/pdf). * feat(plugins): file panels Plugins contribute file panels with addFilePanel. A file tab hosts the panels that claim the file: the built-in Markdown, HTML and image panels, plugin panels, and Source, Paseo's editor, which claims every text file and is always the last candidate. The panel is the tab body; the bar shows the document's status and a switcher between the file's panels (side by side up to three, two on a phone, a menu beyond that). Paseo asks which panel opens a file only when a plugin panel is among several candidates, once per open, and remembers the answer per extension or exact filename when told to. Open with in the tab menu and the switcher change the tab's panel; Always use remembers it. Opening a file at a line switches the tab to Source. Panels read and write the host's document, so dirty state, autosave and conflicts stay Paseo's. DocumentFrame shows bytes documents such as PDFs in the browser's viewer. Replaces the file tab's Preview/Source mode toggle. * feat(file-panels): choose and forget file panels in Settings > Files Settings > Files lists each extension or filename where a plugin panel competes with another candidate, or that has a remembered panel. Each row chooses the panel that opens it, or Ask every time; Ask again forgets the choice. Source appears as a candidate for text files. Panels no connected host offers can be forgotten but not chosen. * docs(plugins): file panel examples and author reference Adds the pdf-panel and markdown-panel examples, the File panels section of the plugin reference, and an e2e spec that installs both examples, opens a PDF in the browser viewer, and edits Markdown through Paseo's document across the switcher, Source, autosave and conflicts. * feat(file-panels): panels read and write through a file handle The file tab stats the file and mounts its panel without reading it. Every panel, built-in or plugin, gets a PluginFile handle: readText, readBytes, url, version-checked writeText/writeBytes (PluginFileConflictError unless force), and watch. useFileText, useFileBytes and useFileUrl read through it, and Loading joins @getpaseo/plugin/client/ui. Panels own their loading state. The host document pipeline, the explorer preview cache and the preview lifecycle are deleted. The daemon classifies a file version from a bounded sniff and writes base64 content as bytes behind fileByteWrites. A deleted file keeps its panel mounted; a read confirms the deletion first, so a remove-and-rewrite is not reported as one. Native Source decodes with builtins and highlights in idle-time slices, the lines around the opening line first, so a large file no longer blocks the JS thread. * fix(file-panels): Settings offers Source where the picker does, and review follow-ups - Settings > Files offers Source on every key except those a built-in panel of another kind (Image) claims, so a plugin that alone claims a text extension shows the same choices as the picker. - Delete the unused isSourceFilePanel. - The Markdown example warns with the file name when its unmount write fails, rather than dropping the edit silently. - Document that PluginFile stat fields are read at render time and that watch or the read hooks are how a panel learns they changed. * refactor(plugin): consolidate the client UI entrypoint --- docs/plugins.md | 18 ++-- .../app/e2e/browser/plugin-host-ui.spec.ts | 2 +- .../app/e2e/browser/plugin-timeline.spec.ts | 7 ++ .../browser/plugin-workspace-panels.spec.ts | 2 +- .../e2e/support/helpers/plugin-timeline.ts | 39 +++++++- packages/app/src/plugins/evaluate.test.ts | 10 +-- packages/app/src/plugins/evaluate.ts | 6 +- packages/app/src/plugins/icons.ts | 2 +- .../app/src/plugins/react-native/modal.tsx | 6 +- .../app/src/plugins/react-native/runtime.ts | 18 ---- .../plugins/react-native/streamed-text.tsx | 22 +++++ .../app/src/plugins/react-native/toast.ts | 2 +- packages/app/src/plugins/react-native/ui.ts | 12 ++- .../cli/src/commands/plugin/scaffold.test.ts | 2 +- packages/plugin/src/boundaries.test.ts | 20 ++++- packages/plugin/src/client/host.test.ts | 18 ++++ packages/plugin/src/client/react-native.ts | 84 +---------------- packages/plugin/src/client/ui.ts | 89 ++++++++++++++++++- .../server/plugins/plugin-sdk-specifiers.ts | 1 + .../inline-thinking/client/thinking.tsx | 6 +- .../inline-thinking/paseo-plugin.json | 2 +- plugin-examples/modal-ui/client/examples.tsx | 2 +- plugin-examples/modal-ui/paseo-plugin.json | 2 +- public-docs/plugins/migration.md | 26 ++++-- public-docs/plugins/reference.md | 62 ++++++------- skills/paseo-plugin/SKILL.md | 4 +- 26 files changed, 283 insertions(+), 181 deletions(-) delete mode 100644 packages/app/src/plugins/react-native/runtime.ts create mode 100644 packages/app/src/plugins/react-native/streamed-text.tsx 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