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
This commit is contained in:
Mohamed Boudra
2026-10-21 11:12:22 +07:00
committed by GitHub
parent 18eabeedab
commit 9bce7dbd25
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
@@ -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 `<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
@@ -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 `<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
@@ -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.
+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