fix(plugins): install local plugins on packaged desktop

The plugin compiler spawned esbuild through app.asar, and the subprocess
imported the React client barrel. Point esbuild at the unpacked binary,
add a react-free @paseo/plugin/server entry, and pass a typed PluginTheme
so plugin UI follows the active theme on desktop and mobile.
This commit is contained in:
Mohamed Boudra
2026-08-19 11:54:55 +02:00
parent ca2ee380df
commit 91024bc029
25 changed files with 643 additions and 153 deletions
+13 -2
View File
@@ -53,8 +53,9 @@ my-plugin/
``` ```
Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only. Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only.
The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a The generated declaration file supplies `@paseo/plugin` and `@paseo/plugin/server` types until the
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes. SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
SDK contract changes.
```json ```json
{ {
@@ -95,6 +96,16 @@ code lives behind filename boundaries:
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. | | `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. | | `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. |
Shared files import contracts from `@paseo/plugin/server`. Client files import hooks from
`@paseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every
`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from
`layout.compact`. See `public-docs/plugins/reference.md`.
| Module | Use it for |
| ---------------------- | -------------------------------------------------------- |
| `@paseo/plugin` | hooks and UI types |
| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
The compiler removes client registrations and imports from the server entry point, and server The compiler removes client registrations and imports from the server entry point, and server
registrations and imports from the client entry point. Importing a `*.server` module from a client registrations and imports from the client entry point. Importing a `*.server` module from a client
module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls
+39
View File
@@ -162,6 +162,45 @@ describe("evaluatePluginClientBundle", () => {
).toThrow("must return a cleanup function"); ).toThrow("must return a cleanup function");
}); });
it("resolves @paseo/plugin/server for shared RPC contracts", () => {
const plugin = evaluatePluginClientBundle(
"example",
`(function(require) {
const { defineRpc, defineAttachmentSource } = require("@paseo/plugin/server");
const search = defineRpc({ name: "issues.search", input: {}, output: {} });
const module = { exports: {} };
module.exports.default = function(plugin) {
plugin.addAttachmentSource(defineAttachmentSource({
id: "issues",
title: "Issue",
icon: "CircleDot",
pickerTitle: "Attach issue",
searchPlaceholder: "Search",
search,
}));
return function() {};
};
return module.exports;
})`,
);
expect(plugin.attachmentSources.map((source) => source.search.name)).toEqual(["issues.search"]);
});
it("rejects modules that are not part of the client runtime", () => {
expect(() =>
evaluatePluginClientBundle(
"example",
`(function(require) {
require("fs");
const module = { exports: {} };
module.exports.default = function() { return function() {}; };
return module.exports;
})`,
),
).toThrow('Module "fs" is not available in plugin client code');
});
it("does not publish partial contributions when setup fails", () => { it("does not publish partial contributions when setup fails", () => {
expect(() => expect(() =>
evaluatePluginClientBundle( evaluatePluginClientBundle(
+3
View File
@@ -159,6 +159,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
useRpc, useRpc,
}; };
} }
if (name === "@paseo/plugin/server") {
return { defineAttachmentSource, defineRpc };
}
if (name === "@tanstack/react-query") return ReactQuery; if (name === "@tanstack/react-query") return ReactQuery;
if (name === "zod") return Zod; if (name === "zod") return Zod;
throw new Error(`Module "${name}" is not available in plugin client code`); throw new Error(`Module "${name}" is not available in plugin client code`);
+6 -6
View File
@@ -1,5 +1,5 @@
import { router, useLocalSearchParams } from "expo-router"; import { router, useLocalSearchParams } from "expo-router";
import type { PluginSurfaceProps } from "@paseo/plugin"; import type { PluginSurfaceProps, PluginTheme } from "@paseo/plugin";
import { ChevronDown, X } from "lucide-react-native"; import { ChevronDown, X } from "lucide-react-native";
import { useCallback, useMemo, useRef, useState, type ComponentType } from "react"; import { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
import { Platform, Pressable, Text, View } from "react-native"; import { Platform, Pressable, Text, View } from "react-native";
@@ -14,6 +14,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
import { resolvePluginIcon } from "./icons"; import { resolvePluginIcon } from "./icons";
import { toPluginTheme } from "./theme";
import { useInstalledPlugin, usePluginInstallations } from "./registry"; import { useInstalledPlugin, usePluginInstallations } from "./registry";
import { buildPluginSurfaceRoute } from "./routes"; import { buildPluginSurfaceRoute } from "./routes";
import { rememberPluginContributionHost } from "./sidebar-groups"; import { rememberPluginContributionHost } from "./sidebar-groups";
@@ -27,10 +28,9 @@ import {
} from "./surface-contribution"; } from "./surface-contribution";
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = []; const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
const EMPTY_THEME_DTO: Record<string, unknown> = {};
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const pluginThemeMapping = (theme: Theme) => ({ const pluginThemeMapping = (theme: Theme) => ({
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>, theme: toPluginTheme(theme),
}); });
const ThemedX = withUnistyles(X); const ThemedX = withUnistyles(X);
const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronDown = withUnistyles(ChevronDown);
@@ -57,18 +57,18 @@ function SurfaceRenderer({
plugin, plugin,
layout, layout,
host, host,
themeDto = EMPTY_THEME_DTO, theme,
}: { }: {
Surface: ComponentType<PluginSurfaceProps>; Surface: ComponentType<PluginSurfaceProps>;
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>; runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>; plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
layout: PluginSurfaceProps["layout"]; layout: PluginSurfaceProps["layout"];
host: PluginSurfaceProps["host"]; host: PluginSurfaceProps["host"];
themeDto?: Record<string, unknown>; theme: PluginTheme;
}) { }) {
return ( return (
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}> <PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<Surface theme={themeDto} host={host} layout={layout} /> <Surface theme={theme} host={host} layout={layout} />
</PluginRuntimeBoundary> </PluginRuntimeBoundary>
); );
} }
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { lightTheme } from "@/styles/theme";
import { toPluginTheme } from "./theme";
describe("toPluginTheme", () => {
it("maps the app theme into the plugin color tokens", () => {
expect(toPluginTheme(lightTheme)).toEqual({
colors: {
surface0: lightTheme.colors.surface0,
foreground: lightTheme.colors.foreground,
foregroundMuted: lightTheme.colors.foregroundMuted,
accent: lightTheme.colors.accent,
accentForeground: lightTheme.colors.accentForeground,
statusDanger: lightTheme.colors.statusDanger,
},
});
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { PluginTheme } from "@paseo/plugin";
import type { Theme } from "@/styles/theme";
export function toPluginTheme(theme: Theme): PluginTheme {
return {
colors: {
surface0: theme.colors.surface0,
foreground: theme.colors.foreground,
foregroundMuted: theme.colors.foregroundMuted,
accent: theme.colors.accent,
accentForeground: theme.colors.accentForeground,
statusDanger: theme.colors.statusDanger,
},
};
}
@@ -1,6 +1,7 @@
import type { import type {
PluginAgentPanelProps, PluginAgentPanelProps,
PluginHostProps, PluginHostProps,
PluginTheme,
PluginWorkspacePanelProps, PluginWorkspacePanelProps,
} from "@paseo/plugin"; } from "@paseo/plugin";
import { PluginClientStateProvider } from "@paseo/plugin/host"; import { PluginClientStateProvider } from "@paseo/plugin/host";
@@ -18,6 +19,7 @@ import { useWorkspaceExists } from "@/stores/session-store-hooks";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import { createPluginClientStateSource } from "../client-state/source"; import { createPluginClientStateSource } from "../client-state/source";
import { toPluginTheme } from "../theme";
import { resolvePluginIcon } from "../icons"; import { resolvePluginIcon } from "../icons";
import { useInstalledPlugin } from "../registry"; import { useInstalledPlugin } from "../registry";
import { PluginRuntimeBoundary } from "../runtime-boundary"; import { PluginRuntimeBoundary } from "../runtime-boundary";
@@ -25,9 +27,8 @@ import { createPluginSurfaceRuntime } from "../surface-runtime";
import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { resolvePluginWorkspacePanel } from "./resolution"; import { resolvePluginWorkspacePanel } from "./resolution";
const EMPTY_THEME_DTO: Record<string, unknown> = {};
const pluginThemeMapping = (theme: Theme) => ({ const pluginThemeMapping = (theme: Theme) => ({
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>, theme: toPluginTheme(theme),
}); });
function resolvePlatform(): PluginHostProps["layout"]["platform"] { function resolvePlatform(): PluginHostProps["layout"]["platform"] {
@@ -36,7 +37,7 @@ function resolvePlatform(): PluginHostProps["layout"]["platform"] {
return "web"; return "web";
} }
function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<string, unknown> }) { function PluginPanelBody({ theme }: { theme: PluginTheme }) {
const { serverId, workspaceId, target } = usePaneContext(); const { serverId, workspaceId, target } = usePaneContext();
invariant(target.kind === "plugin", "PluginPanel requires plugin target"); invariant(target.kind === "plugin", "PluginPanel requires plugin target");
const plugin = useInstalledPlugin(serverId, target.pluginId); const plugin = useInstalledPlugin(serverId, target.pluginId);
@@ -75,7 +76,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<str
if (contribution.context === "workspace") { if (contribution.context === "workspace") {
const props: PluginWorkspacePanelProps = { const props: PluginWorkspacePanelProps = {
context: "workspace", context: "workspace",
theme: themeDto, theme,
host, host,
layout, layout,
workspaceId, workspaceId,
@@ -86,7 +87,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<str
} else if (agentExists && target.context === "agent") { } else if (agentExists && target.context === "agent") {
const props: PluginAgentPanelProps = { const props: PluginAgentPanelProps = {
context: "agent", context: "agent",
theme: themeDto, theme,
host, host,
layout, layout,
workspaceId, workspaceId,
@@ -50,7 +50,7 @@ describe("plugin scaffold", () => {
await Promise.all([ await Promise.all([
writeFile( writeFile(
path.join(directory, "inspect.shared.ts"), path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@paseo/plugin"; `import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
export const inspect = defineRpc({ export const inspect = defineRpc({
@@ -62,7 +62,7 @@ export const inspect = defineRpc({
), ),
writeFile( writeFile(
path.join(directory, "inspect.server.ts"), path.join(directory, "inspect.server.ts"),
`import type { PluginHandlerContext } from "@paseo/plugin"; `import type { PluginHandlerContext } from "@paseo/plugin/server";
import type { output as ZodOutput } from "zod"; import type { output as ZodOutput } from "zod";
import { inspect } from "./inspect.shared"; import { inspect } from "./inspect.shared";
+105 -56
View File
@@ -2,13 +2,95 @@ import { mkdir, readdir, writeFile } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { PluginIdSchema } from "@getpaseo/protocol/messages"; import { PluginIdSchema } from "@getpaseo/protocol/messages";
const SDK_DECLARATIONS = `declare module "@paseo/plugin" { const SDK_DECLARATIONS = `declare module "@paseo/plugin/server" {
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client"; import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
export interface PluginRpcContract<
InputSchema extends ZodType = ZodType,
OutputSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
output: OutputSchema;
}
export interface PluginAttachmentItem {
id: string;
identifier: string;
title: string;
subtitle?: string;
url: string;
text: string;
resourceType: string;
}
export interface PluginAttachmentSearchPayload {
items: PluginAttachmentItem[];
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
}
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
name: string;
input: InputSchema;
output: OutputSchema;
}): PluginRpcContract<InputSchema, OutputSchema>;
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition;
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
}
declare module "@paseo/plugin" {
import type { ComponentType } from "react";
import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type {
PluginAttachmentSourceContribution,
PluginHandlerContext,
PluginRpcContract,
} from "@paseo/plugin/server";
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
defineAttachmentSource,
defineRpc,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
type PluginAttachmentSourceContribution,
type PluginHandlerContext,
type PluginRpcContract,
} from "@paseo/plugin/server";
export interface PluginTheme {
readonly colors: {
readonly surface0: string;
readonly foreground: string;
readonly foregroundMuted: string;
readonly accent: string;
readonly accentForeground: string;
readonly statusDanger: string;
};
}
export interface PluginHostProps { export interface PluginHostProps {
theme: Record<string, unknown>; theme: PluginTheme;
host: { id: string; label: string }; host: { id: string; label: string };
layout: { compact: boolean; platform: "ios" | "android" | "web" }; layout: { compact: boolean; platform: "ios" | "android" | "web" };
} }
@@ -65,38 +147,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
| { id: string; title: string; icon: string; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> } | { id: string; title: string; icon: string; context: "workspace"; Component: ComponentType<PluginWorkspacePanelProps> }
| { id: string; title: string; icon: string; context: "agent"; Component: ComponentType<PluginAgentPanelProps> }; | { id: string; title: string; icon: string; context: "agent"; Component: ComponentType<PluginAgentPanelProps> };
export interface PluginRpcContract<
InputSchema extends ZodType = ZodType,
OutputSchema extends ZodType = ZodType,
> {
name: string;
input: InputSchema;
output: OutputSchema;
}
export interface PluginAttachmentItem {
id: string;
identifier: string;
title: string;
subtitle?: string;
url: string;
text: string;
resourceType: string;
}
export interface PluginAttachmentSearchPayload {
items: PluginAttachmentItem[];
}
export interface PluginAttachmentSourceContribution {
id: string;
title: string;
icon: string;
pickerTitle: string;
searchPlaceholder: string;
search: PluginRpcContract;
}
export interface PluginSidebarContribution { export interface PluginSidebarContribution {
id: string; id: string;
title: string; title: string;
@@ -140,10 +190,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> } | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise<void> }
| { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise<void> }; | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise<void> };
export interface PluginHandlerContext {
paseo: PaseoApi;
}
export interface PluginContext { export interface PluginContext {
handle<InputSchema extends ZodType, OutputSchema extends ZodType>( handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>, contract: PluginRpcContract<InputSchema, OutputSchema>,
@@ -162,12 +208,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
export type PluginCleanup = () => void | Promise<void>; export type PluginCleanup = () => void | Promise<void>;
export type PluginContribution = (plugin: PluginContext) => PluginCleanup; export type PluginContribution = (plugin: PluginContext) => PluginCleanup;
export function defineRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(definition: {
name: string;
input: InputSchema;
output: OutputSchema;
}): PluginRpcContract<InputSchema, OutputSchema>;
export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>( export function useRpc<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>, contract: PluginRpcContract<InputSchema, OutputSchema>,
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>; ): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
@@ -183,13 +223,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
agentId: string, agentId: string,
selector: (agent: PluginAgentSnapshot) => Selection, selector: (agent: PluginAgentSnapshot) => Selection,
): Selection | null; ): Selection | null;
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition;
export const PluginAttachmentItemSchema: import("zod").ZodType<PluginAttachmentItem>;
export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType<PluginAttachmentSearchPayload>;
} }
`; `;
@@ -218,11 +251,27 @@ export default function contribute(plugin: PluginContext) {
} }
`; `;
const CLIENT_SURFACE = `import React from "react"; const CLIENT_SURFACE = `import type { PluginSurfaceProps } from "@paseo/plugin";
import { Text } from "react-native"; import React, { useMemo } from "react";
import { Text, View } from "react-native";
export function MainSurface() { export function MainSurface({ theme, layout }: PluginSurfaceProps) {
return <Text>Hello from my plugin</Text>; const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
backgroundColor: theme.colors.surface0,
},
text: { color: theme.colors.foreground },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.text}>Hello from my plugin</Text>
</View>
);
} }
`; `;
+5
View File
@@ -13,6 +13,11 @@
"source": "./src/index.ts", "source": "./src/index.ts",
"default": "./dist/index.js" "default": "./dist/index.js"
}, },
"./server": {
"types": "./dist/server.d.ts",
"source": "./src/server.ts",
"default": "./dist/server.js"
},
"./host": { "./host": {
"types": "./dist/host.d.ts", "types": "./dist/host.d.ts",
"source": "./src/host.ts", "source": "./src/host.ts",
+12 -1
View File
@@ -3,8 +3,19 @@ import type { PaseoApi } from "@getpaseo/client";
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
import type { PluginRpcContract } from "./rpc.js"; import type { PluginRpcContract } from "./rpc.js";
export interface PluginTheme {
readonly colors: {
readonly surface0: string;
readonly foreground: string;
readonly foregroundMuted: string;
readonly accent: string;
readonly accentForeground: string;
readonly statusDanger: string;
};
}
export interface PluginHostProps { export interface PluginHostProps {
theme: Record<string, unknown>; theme: PluginTheme;
host: { host: {
id: string; id: string;
label: string; label: string;
+5 -9
View File
@@ -1,9 +1,12 @@
export { export {
PluginAttachmentItemSchema, PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema, PluginAttachmentSearchPayloadSchema,
defineAttachmentSource,
defineRpc,
type PluginAttachmentItem, type PluginAttachmentItem,
type PluginAttachmentSearchPayload, type PluginAttachmentSearchPayload,
} from "./attachments.js"; type PluginRpcContract,
} from "./server.js";
export type { export type {
PluginAttachmentSourceContribution, PluginAttachmentSourceContribution,
PluginAgentCommandContext, PluginAgentCommandContext,
@@ -17,6 +20,7 @@ export type {
PluginGlobalCommandContext, PluginGlobalCommandContext,
PluginHandlerContext, PluginHandlerContext,
PluginHostProps, PluginHostProps,
PluginTheme,
PluginSidebarContribution, PluginSidebarContribution,
PluginSurfaceContribution, PluginSurfaceContribution,
PluginSurfaceProps, PluginSurfaceProps,
@@ -27,12 +31,4 @@ export type {
} from "./contracts.js"; } from "./contracts.js";
export { usePaseo } from "./paseo-context.js"; export { usePaseo } from "./paseo-context.js";
export { useAgent, useWorkspace } from "./client-state.js"; export { useAgent, useWorkspace } from "./client-state.js";
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export { useRpc } from "./rpc-context.js"; export { useRpc } from "./rpc-context.js";
export { defineRpc, type PluginRpcContract } from "./rpc.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
+57
View File
@@ -0,0 +1,57 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const srcDir = path.dirname(fileURLToPath(import.meta.url));
const forbiddenSpecifiers = new Set([
"react",
"react/jsx-runtime",
"react-native",
"use-sync-external-store",
"use-sync-external-store/shim/with-selector",
]);
function localFile(fromFile: string, specifier: string): string {
const withoutJs = specifier.replace(/\.js$/, "");
for (const candidate of [`${withoutJs}.ts`, `${withoutJs}.tsx`, withoutJs]) {
const resolved = path.resolve(path.dirname(fromFile), candidate);
if (existsSync(resolved)) return resolved;
}
throw new Error(`Could not resolve ${specifier} from ${fromFile}`);
}
function valueImportSpecifiers(source: string): string[] {
const specifiers: string[] = [];
const pattern = /(?:^|\n)(?:export|import)\s+(?!type\b)(?:[\s\S]*?)from\s+["']([^"']+)["']/g;
for (const match of source.matchAll(pattern)) {
if (match[1]) specifiers.push(match[1]);
}
return specifiers;
}
function collectBareSpecifiers(entry: string): string[] {
const seen = new Set<string>();
const bare: string[] = [];
const queue = [entry];
while (queue.length > 0) {
const file = queue.pop();
if (!file || seen.has(file)) continue;
seen.add(file);
for (const specifier of valueImportSpecifiers(readFileSync(file, "utf8"))) {
if (specifier.startsWith(".")) {
queue.push(localFile(file, specifier));
continue;
}
bare.push(specifier);
}
}
return bare;
}
describe("@paseo/plugin/server", () => {
it("does not load react or the client hook graph", () => {
const specifiers = collectBareSpecifiers(path.join(srcDir, "server.ts"));
expect(specifiers.filter((specifier) => forbiddenSpecifiers.has(specifier))).toEqual([]);
});
});
+16
View File
@@ -0,0 +1,16 @@
import type { PluginAttachmentSourceContribution } from "./contracts.js";
export {
PluginAttachmentItemSchema,
PluginAttachmentSearchPayloadSchema,
type PluginAttachmentItem,
type PluginAttachmentSearchPayload,
} from "./attachments.js";
export type { PluginAttachmentSourceContribution, PluginHandlerContext } from "./contracts.js";
export { defineRpc, type PluginRpcContract } from "./rpc.js";
export function defineAttachmentSource<Definition extends PluginAttachmentSourceContribution>(
definition: Definition,
): Definition {
return definition;
}
@@ -0,0 +1,105 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
compilePlugin,
resolveExistingAsarUnpackedEsbuildBinary,
unpackedEsbuildBinaryFromPackageDir,
} from "./compiler.js";
const asarEsbuildDir = path.join("Resources", "app.asar", "node_modules", "esbuild");
describe("asar esbuild binary resolution", () => {
it("rewrites an asar package path to the unpacked platform binary", () => {
expect(unpackedEsbuildBinaryFromPackageDir(asarEsbuildDir, "darwin", "arm64")).toBe(
path.join(
"Resources",
"app.asar.unpacked",
"node_modules",
"@esbuild",
"darwin-arm64",
"bin",
"esbuild",
),
);
expect(unpackedEsbuildBinaryFromPackageDir(asarEsbuildDir, "win32", "x64")).toBe(
path.join(
"Resources",
"app.asar.unpacked",
"node_modules",
"@esbuild",
"win32-x64",
"esbuild.exe",
),
);
});
it("ignores package paths that are not inside app.asar", () => {
expect(
unpackedEsbuildBinaryFromPackageDir(path.join("node_modules", "esbuild"), "darwin", "arm64"),
).toBeNull();
});
it("returns null when the unpacked binary is missing", () => {
expect(
resolveExistingAsarUnpackedEsbuildBinary(asarEsbuildDir, "darwin", "arm64", () => false),
).toBeNull();
});
it("returns the unpacked path when the binary exists", () => {
expect(
resolveExistingAsarUnpackedEsbuildBinary(asarEsbuildDir, "linux", "x64", () => true),
).toBe(
path.join(
"Resources",
"app.asar.unpacked",
"node_modules",
"@esbuild",
"linux-x64",
"bin",
"esbuild",
),
);
});
});
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })),
);
});
describe("plugin author module externals", () => {
it("leaves @paseo/plugin/server external in both bundles", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-"));
temporaryDirectories.push(directory);
const entryPath = path.join(directory, "index.ts");
await writeFile(
entryPath,
`import type { PluginContext } from "@paseo/plugin";
import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod";
const ping = defineRpc({
name: "ping",
input: z.object({}),
output: z.object({ ok: z.boolean() }),
});
export default function contribute(plugin: PluginContext) {
plugin.handle(ping, async () => ({ ok: true }));
return () => undefined;
}
`,
);
const { clientBundle, serverBundle } = await compilePlugin(entryPath);
expect(clientBundle).toContain("@paseo/plugin/server");
expect(serverBundle).toContain("@paseo/plugin/server");
expect(clientBundle).not.toContain("Invalid plugin RPC method");
expect(serverBundle).not.toContain("Invalid plugin RPC method");
});
});
+54 -2
View File
@@ -1,7 +1,57 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path"; import path from "node:path";
import { parse } from "@babel/parser"; import { parse } from "@babel/parser";
import { build, type Plugin } from "esbuild"; import type { Plugin } from "esbuild";
// esbuild resolves its own platform binary via require.resolve() the first time its
// module is evaluated. Inside the packaged desktop app that resolves to a path under
// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked.
// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path
// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads.
export function unpackedEsbuildBinaryFromPackageDir(
esbuildPackageDir: string,
platform: NodeJS.Platform,
arch: string,
): string | null {
const asarSegment = `${path.sep}app.asar${path.sep}`;
const asarIndex = esbuildPackageDir.indexOf(asarSegment);
if (asarIndex === -1) return null;
return path.join(
esbuildPackageDir.slice(0, asarIndex),
"app.asar.unpacked",
"node_modules",
`@esbuild/${platform}-${arch}`,
...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]),
);
}
export function resolveExistingAsarUnpackedEsbuildBinary(
esbuildPackageDir: string,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
exists: (file: string) => boolean = existsSync,
): string | null {
const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch);
return binaryPath && exists(binaryPath) ? binaryPath : null;
}
function resolveAsarUnpackedEsbuildBinary(): string | null {
const require = createRequire(import.meta.url);
let esbuildDir: string;
try {
esbuildDir = path.dirname(require.resolve("esbuild/package.json"));
} catch {
return null;
}
return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir);
}
if (!process.env.ESBUILD_BINARY_PATH) {
const unpackedBinary = resolveAsarUnpackedEsbuildBinary();
if (unpackedBinary) process.env.ESBUILD_BINARY_PATH = unpackedBinary;
}
type PluginBuildTarget = "client" | "server"; type PluginBuildTarget = "client" | "server";
@@ -228,6 +278,7 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
} }
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> { async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
const { build } = await import("esbuild");
const source = await readFile(entryPath, "utf8"); const source = await readFile(entryPath, "utf8");
const filteredSource = filterEntrypoint(source, target); const filteredSource = filterEntrypoint(source, target);
const result = await build({ const result = await build({
@@ -245,13 +296,14 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
target === "client" target === "client"
? [ ? [
"@paseo/plugin", "@paseo/plugin",
"@paseo/plugin/server",
"@tanstack/react-query", "@tanstack/react-query",
"react", "react",
"react/jsx-runtime", "react/jsx-runtime",
"react-native", "react-native",
"zod", "zod",
] ]
: ["@paseo/plugin", "zod"], : ["@paseo/plugin", "@paseo/plugin/server", "zod"],
plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)], plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)],
logLevel: "silent", logLevel: "silent",
treeShaking: true, treeShaking: true,
@@ -5,7 +5,7 @@ import {
defineRpc, defineRpc,
type PluginHandlerContext, type PluginHandlerContext,
type PluginRpcContract, type PluginRpcContract,
} from "@paseo/plugin"; } from "@paseo/plugin/server";
import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { createPluginDaemonTransportFactory } from "./daemon-transport.js"; import { createPluginDaemonTransportFactory } from "./daemon-transport.js";
@@ -61,8 +61,10 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void {
handlers.set(method, { contract: { ...contract, name: method }, handler }); handlers.set(method, { contract: { ...contract, name: method }, handler });
} }
const pluginAuthorRuntime = { defineAttachmentSource, defineRpc };
function runtimeRequire(name: string): unknown { function runtimeRequire(name: string): unknown {
if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc }; if (name === "@paseo/plugin" || name === "@paseo/plugin/server") return pluginAuthorRuntime;
return nodeRequire(name); return nodeRequire(name);
} }
@@ -597,7 +597,7 @@ export function Surface() {
), ),
writeFile( writeFile(
path.join(directory, "inspect.shared.ts"), path.join(directory, "inspect.shared.ts"),
`import { defineRpc } from "@paseo/plugin"; `import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
export const inspectRpc = defineRpc({ export const inspectRpc = defineRpc({
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PluginAttachmentSearchPayload } from "@paseo/plugin"; import type { PluginAttachmentSearchPayload } from "@paseo/plugin/server";
import type { output as ZodOutput } from "zod"; import type { output as ZodOutput } from "zod";
import { searchIssuesRpc } from "./issues.shared"; import { searchIssuesRpc } from "./issues.shared";
import { createLinearIssueSearch } from "./linear"; import { createLinearIssueSearch } from "./linear";
+1 -1
View File
@@ -1,4 +1,4 @@
import { defineAttachmentSource, defineRpc } from "@paseo/plugin"; import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
const SearchPayloadSchema = z.object({ const SearchPayloadSchema = z.object({
@@ -1,4 +1,4 @@
import { defineRpc } from "@paseo/plugin"; import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
export const incrementRpc = defineRpc({ export const incrementRpc = defineRpc({
+13 -21
View File
@@ -2,36 +2,28 @@ import { useMutation } from "@tanstack/react-query";
import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@paseo/plugin"; import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@paseo/plugin";
import React, { useCallback, useMemo } from "react"; import React, { useCallback, useMemo } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import { z } from "zod";
import { incrementRpc } from "./increment.shared"; import { incrementRpc } from "./increment.shared";
const ExampleThemeSchema = z.object({ export function ExamplePanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
colors: z.object({
surface0: z.string(),
foreground: z.string(),
foregroundMuted: z.string(),
accent: z.string(),
accentForeground: z.string(),
statusDanger: z.string(),
}),
});
export function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) {
const { colors } = ExampleThemeSchema.parse(theme);
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name })); const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
const callIncrement = useRpc(incrementRpc); const callIncrement = useRpc(incrementRpc);
const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement }); const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement });
const value = data?.value ?? 0; const value = data?.value ?? 0;
const styles = useMemo( const styles = useMemo(
() => ({ () => ({
screen: { flex: 1, padding: 24, gap: 16, backgroundColor: colors.surface0 }, screen: {
title: { color: colors.foreground, fontSize: 24 }, flex: 1,
detail: { color: colors.foregroundMuted }, padding: layout.compact ? 16 : 24,
button: { padding: 14, borderRadius: 10, backgroundColor: colors.accent }, gap: 16,
buttonText: { color: colors.accentForeground, textAlign: "center" as const }, backgroundColor: theme.colors.surface0,
error: { color: colors.statusDanger }, },
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
detail: { color: theme.colors.foregroundMuted },
button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent },
buttonText: { color: theme.colors.accentForeground, textAlign: "center" as const },
error: { color: theme.colors.statusDanger },
}), }),
[colors], [theme, layout.compact],
); );
const handleIncrement = useCallback(() => { const handleIncrement = useCallback(() => {
mutate({ value }); mutate({ value });
+24 -10
View File
@@ -34,29 +34,43 @@ npm install
`init` creates a strict TypeScript project. It does not run the package manager. `index.ts` registers contributions; client UI lives in `*.client.tsx` files. `init` creates a strict TypeScript project. It does not run the package manager. `index.ts` registers contributions; client UI lives in `*.client.tsx` files.
Plugins run on desktop, browser, iOS, and Android. Paseo ships several themes. Color every `Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. Hardcoded black text fails in dark themes.
Replace `main.client.tsx` with: Replace `main.client.tsx` with:
```tsx ```tsx
import { type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin"; import { type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin";
import { StyleSheet, Text, View } from "react-native"; import { useMemo } from "react";
import { Text, View } from "react-native";
export function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) { export function WorkspaceOverview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({ const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({
name, name,
directory, directory,
})); }));
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: layout.compact ? 8 : 12,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
label: { color: theme.colors.foregroundMuted },
detail: { color: theme.colors.foreground },
}),
[theme, layout.compact],
);
return ( return (
<View style={styles.screen}> <View style={styles.screen}>
<Text style={styles.title}>{workspace?.name}</Text> <Text style={styles.title}>{workspace?.name}</Text>
<Text>{workspace?.directory}</Text> <Text style={styles.label}>Directory</Text>
<Text style={styles.detail}>{workspace?.directory}</Text>
</View> </View>
); );
} }
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, gap: 8 },
title: { fontSize: 24 },
});
``` ```
Replace `index.ts` with: Replace `index.ts` with:
@@ -86,7 +100,7 @@ export default function contribute(plugin: PluginContext) {
} }
``` ```
The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs such as `StyleSheet.create`; Paseo excludes them from the daemon bundle. The panel works in the desktop, browser, iOS, and Android clients. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs; Paseo excludes them from the daemon bundle. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. See [Theme and layout](/docs/plugins/reference#theme-and-layout) for the required tokens.
## Check and install it ## Check and install it
@@ -96,7 +110,7 @@ paseo plugin install /absolute/path/to/workspace-plugin
paseo plugin ls paseo plugin ls
``` ```
Open a workspace, open the Command Center, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it. Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it.
## Edit and reload ## Edit and reload
+79 -16
View File
@@ -41,7 +41,7 @@ The manifest contains the default plugin ID:
Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens. Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens.
The generated declaration file supplies `@paseo/plugin` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes. The generated declaration file supplies `@paseo/plugin` and `@paseo/plugin/server` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes.
Add runtime-specific files as the plugin grows: Add runtime-specific files as the plugin grows:
@@ -58,6 +58,13 @@ my-plugin/
| `*.server.ts` | Node APIs, local resources, credentials, and RPC handlers. | | `*.server.ts` | Node APIs, local resources, credentials, and RPC handlers. |
| `*.shared.ts` | Zod RPC contracts and plain values imported by both runtimes. | | `*.shared.ts` | Zod RPC contracts and plain values imported by both runtimes. |
## SDK modules
| Module | Use it for |
| ---------------------- | -------------------------------------------------------- |
| `@paseo/plugin` | hooks and UI types |
| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types |
Paseo rejects imports from `*.server` files into client modules and imports from `*.client` files into server modules. Keep shared modules free of Node and React Native runtime code. Paseo rejects imports from `*.server` files into client modules and imports from `*.client` files into server modules. Keep shared modules free of Node and React Native runtime code.
## Entry point and cleanup ## Entry point and cleanup
@@ -84,10 +91,28 @@ Register a component, then point a sidebar item at its surface ID:
```tsx ```tsx
import type { PluginSurfaceProps } from "@paseo/plugin"; import type { PluginSurfaceProps } from "@paseo/plugin";
import { Text } from "react-native"; import { useMemo } from "react";
import { Text, View } from "react-native";
export function Main({ host, layout }: PluginSurfaceProps) { export function Main({ theme, host, layout }: PluginSurfaceProps) {
return <Text>{`${host.label} · ${layout.platform}`}</Text>; const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground },
detail: { color: theme.colors.foregroundMuted },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{host.label}</Text>
<Text style={styles.detail}>{layout.platform}</Text>
</View>
);
} }
``` ```
@@ -111,15 +136,33 @@ export default function contribute(plugin: PluginContext) {
`PluginSurfaceProps` contains: `PluginSurfaceProps` contains:
| Field | Meaning | | Field | Meaning |
| -------- | --------------------------------------------------------- | | -------- | ------------------------------------------------------------ |
| `theme` | Paseo theme values. Validate the keys your surface reads. | | `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. |
| `host` | Selected host `id` and display `label`. | | `host` | Selected host `id` and display `label`. |
| `layout` | `compact` and the `ios`, `android`, or `web` platform. | | `layout` | `compact` and the `ios`, `android`, or `web` platform. |
Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body. Icons use [Lucide](https://lucide.dev/icons/) names. Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body. Icons use [Lucide](https://lucide.dev/icons/) names.
Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install them locally for typechecking; Paseo provides the client runtime instances. ## Theme and layout
Plugin UI runs on desktop, browser, iOS, and Android, across every Paseo theme. `theme` is a typed `PluginTheme`. Color and spacing must come from those props. Unstyled `Text` is black and fails in dark themes.
Recreate styles when `theme` or `layout.compact` changes.
| Key | Required for | Use it for |
| ------------------------------ | -------------------------- | ----------------------------------- |
| `theme.colors.foreground` | Every primary `Text` | Titles and body copy |
| `theme.colors.foregroundMuted` | Secondary `Text` | Labels and supporting copy |
| `theme.colors.surface0` | Root view | Panel background |
| `layout.compact` | Padding and stacking | `true` on mobile and narrow windows |
| `layout.platform` | Platform-specific behavior | `ios`, `android`, or `web` |
Do not hardcode `#000`, `#fff`, or React Native's default text color. Primary copy uses `foreground`. Labels use `foregroundMuted`. Tighten padding when `layout.compact` is true.
Workspace and agent panels receive the same `theme` and `layout` fields.
Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@paseo/plugin`, and `@paseo/plugin/server`. Install them locally for typechecking; Paseo provides the client runtime instances.
## Workspace panels ## Workspace panels
@@ -129,12 +172,30 @@ Register one panel for workspace or agent context:
```tsx ```tsx
import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@paseo/plugin"; import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@paseo/plugin";
import { Text } from "react-native"; import { useMemo } from "react";
import { Text, View } from "react-native";
export function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) { export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) {
const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name);
const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); const agent = useAgent(agentId, ({ id, title }) => ({ id, title }));
return <Text>{`${workspaceName} · ${agent?.title ?? agent?.id}`}</Text>; const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground },
detail: { color: theme.colors.foregroundMuted },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{workspaceName}</Text>
<Text style={styles.detail}>{agent?.title ?? agent?.id}</Text>
</View>
);
} }
``` ```
@@ -215,10 +276,12 @@ Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC
## Command Center items ## Command Center items
Open the Command Center with **⌘K** on macOS or **Ctrl+K** on Windows and Linux, then search for the item title.
Register an action and open a panel from the callback: Register an action and open a panel from the callback:
```tsx ```tsx
import { defineRpc } from "@paseo/plugin"; import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
const refreshReview = defineRpc({ const refreshReview = defineRpc({
@@ -314,7 +377,7 @@ Define one contract with Zod, handle it in the subprocess, and call it from the
`greeting.shared.ts`: `greeting.shared.ts`:
```ts ```ts
import { defineRpc } from "@paseo/plugin"; import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
export const greeting = defineRpc({ export const greeting = defineRpc({
@@ -411,7 +474,7 @@ An attachment source searches external resources and returns a stable text snaps
`issues.shared.ts`: `issues.shared.ts`:
```ts ```ts
import { defineAttachmentSource, defineRpc } from "@paseo/plugin"; import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
export const searchIssues = defineRpc({ export const searchIssues = defineRpc({
+58 -17
View File
@@ -62,16 +62,34 @@ Cleanup can be async. Use it for timers, watchers, sockets, and other resources
## Add a workspace panel ## Add a workspace panel
Workspace panels live beside agents, terminals, files, and diffs. Their props contain stable IDs; Workspace panels live beside agents, terminals, files, and diffs. Plugins run on desktop and
required-selector hooks read cached client state without subscribing to unrelated fields: mobile, and Paseo has multiple themes. Every `Text` must take its color from `theme.colors`.
Use `layout.compact` for padding and stacking. Unstyled text is black and fails in dark themes.
```tsx ```tsx
import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin"; import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin";
import { Text } from "react-native"; import { useMemo } from "react";
import { Text, View } from "react-native";
function Overview({ workspaceId }: PluginWorkspacePanelProps) { function Overview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
const name = useWorkspace(workspaceId, (workspace) => workspace.name); const name = useWorkspace(workspaceId, (workspace) => workspace.name);
return <Text>{name}</Text>; const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: layout.compact ? 8 : 12,
backgroundColor: theme.colors.surface0,
},
title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.title}>{name}</Text>
</View>
);
} }
export default function contribute(plugin: PluginContext) { export default function contribute(plugin: PluginContext) {
@@ -102,24 +120,39 @@ the active workspace or agent. Command callbacks receive the selected host's `pa
## Add a sidebar surface ## Add a sidebar surface
Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item: Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item. Color text from `theme.colors` and pad from `layout.compact`.
```tsx ```tsx
import type { PluginContext } from "@paseo/plugin"; import type { PluginContext, PluginSurfaceProps } from "@paseo/plugin";
import React, { useState } from "react"; import React, { useMemo, useState } from "react";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
function Counter() { function Counter({ theme, layout }: PluginSurfaceProps) {
const [count, setCount] = useState(0); const [count, setCount] = useState(0);
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
gap: 16,
backgroundColor: theme.colors.surface0,
},
count: { color: theme.colors.foreground, fontSize: layout.compact ? 36 : 48 },
button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent },
buttonText: { color: theme.colors.accentForeground, textAlign: "center" as const },
}),
[theme, layout.compact],
);
return ( return (
<View style={{ flex: 1, padding: 24, gap: 16 }}> <View style={styles.screen}>
<Text style={{ fontSize: 48 }}>{count}</Text> <Text style={styles.count}>{count}</Text>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`Increment counter, currently ${count}`} accessibilityLabel={`Increment counter, currently ${count}`}
onPress={() => setCount((value) => value + 1)} onPress={() => setCount((value) => value + 1)}
style={styles.button}
> >
<Text>Count me in</Text> <Text style={styles.buttonText}>Count me in</Text>
</Pressable> </Pressable>
</View> </View>
); );
@@ -137,9 +170,14 @@ export default function contribute(plugin: PluginContext) {
} }
``` ```
Icons are Lucide icon names. `PluginSurfaceProps` provides `theme`, selected `host`, and `layout`. Validate the theme keys the surface reads. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client. Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client.
Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@paseo/plugin`, and `@paseo/plugin/server`. Install dependencies locally for typechecking; Paseo supplies these runtime modules.
| Module | Use it for |
| ---------------------- | ---------------------------------------------------- |
| `@paseo/plugin` | hooks and UI types |
| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, handler types |
## Choose the correct API ## Choose the correct API
@@ -183,7 +221,9 @@ The API covers workspaces, agents, providers, and daemon config. It omits connec
Define one Zod contract, register its subprocess handler, and call it with `useRpc()`: Define one Zod contract, register its subprocess handler, and call it with `useRpc()`:
```tsx ```tsx
import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin"; import type { PluginContext } from "@paseo/plugin";
import { useRpc } from "@paseo/plugin";
import { defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
const greeting = defineRpc({ const greeting = defineRpc({
@@ -235,7 +275,8 @@ daemon log. Never log credentials or other secrets.
Define a search RPC and register a declarative source: Define a search RPC and register a declarative source:
```tsx ```tsx
import { defineAttachmentSource, defineRpc, type PluginContext } from "@paseo/plugin"; import type { PluginContext } from "@paseo/plugin";
import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server";
import { z } from "zod"; import { z } from "zod";
const searchIssues = defineRpc({ const searchIssues = defineRpc({
@@ -328,7 +369,7 @@ After a change:
1. Run `npm run typecheck`. 1. Run `npm run typecheck`.
2. Install or reload the exact runtime ID. 2. Install or reload the exact runtime ID.
3. Run `paseo plugin ls` and require `running` with no error. 3. Run `paseo plugin ls` and require `running` with no error.
4. Confirm the contribution on the intended host. For UI work, check both a wide client and a compact/mobile client when available. 4. Confirm the contribution on the intended host. Open the Command Center with **⌘K** (macOS) or **Ctrl+K** (Windows/Linux). For UI work, check a wide desktop window and a compact/mobile client, and switch theme to confirm text still uses `foreground` / `foregroundMuted`.
5. Exercise the changed action or RPC, including its error state. 5. Exercise the changed action or RPC, including its error state.
Common failures: Common failures: