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:
+13
-2
@@ -53,8 +53,9 @@ my-plugin/
|
||||
```
|
||||
|
||||
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
|
||||
public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes.
|
||||
The generated declaration file supplies `@paseo/plugin` and `@paseo/plugin/server` types until the
|
||||
SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the
|
||||
SDK contract changes.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -95,6 +96,16 @@ code lives behind filename boundaries:
|
||||
| `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. |
|
||||
| `*.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
|
||||
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
|
||||
|
||||
@@ -162,6 +162,45 @@ describe("evaluatePluginClientBundle", () => {
|
||||
).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", () => {
|
||||
expect(() =>
|
||||
evaluatePluginClientBundle(
|
||||
|
||||
@@ -159,6 +159,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate
|
||||
useRpc,
|
||||
};
|
||||
}
|
||||
if (name === "@paseo/plugin/server") {
|
||||
return { defineAttachmentSource, defineRpc };
|
||||
}
|
||||
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,5 +1,5 @@
|
||||
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 { useCallback, useMemo, useRef, useState, type ComponentType } from "react";
|
||||
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 { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { resolvePluginIcon } from "./icons";
|
||||
import { toPluginTheme } from "./theme";
|
||||
import { useInstalledPlugin, usePluginInstallations } from "./registry";
|
||||
import { buildPluginSurfaceRoute } from "./routes";
|
||||
import { rememberPluginContributionHost } from "./sidebar-groups";
|
||||
@@ -27,10 +28,9 @@ import {
|
||||
} from "./surface-contribution";
|
||||
|
||||
const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = [];
|
||||
const EMPTY_THEME_DTO: Record<string, unknown> = {};
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const pluginThemeMapping = (theme: Theme) => ({
|
||||
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
|
||||
theme: toPluginTheme(theme),
|
||||
});
|
||||
const ThemedX = withUnistyles(X);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
@@ -57,18 +57,18 @@ function SurfaceRenderer({
|
||||
plugin,
|
||||
layout,
|
||||
host,
|
||||
themeDto = EMPTY_THEME_DTO,
|
||||
theme,
|
||||
}: {
|
||||
Surface: ComponentType<PluginSurfaceProps>;
|
||||
runtime: NonNullable<ReturnType<typeof createPluginSurfaceRuntime>>;
|
||||
plugin: NonNullable<ReturnType<typeof useInstalledPlugin>>;
|
||||
layout: PluginSurfaceProps["layout"];
|
||||
host: PluginSurfaceProps["host"];
|
||||
themeDto?: Record<string, unknown>;
|
||||
theme: PluginTheme;
|
||||
}) {
|
||||
return (
|
||||
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
|
||||
<Surface theme={themeDto} host={host} layout={layout} />
|
||||
<Surface theme={theme} host={host} layout={layout} />
|
||||
</PluginRuntimeBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
PluginAgentPanelProps,
|
||||
PluginHostProps,
|
||||
PluginTheme,
|
||||
PluginWorkspacePanelProps,
|
||||
} from "@paseo/plugin";
|
||||
import { PluginClientStateProvider } from "@paseo/plugin/host";
|
||||
@@ -18,6 +19,7 @@ import { useWorkspaceExists } from "@/stores/session-store-hooks";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { createPluginClientStateSource } from "../client-state/source";
|
||||
import { toPluginTheme } from "../theme";
|
||||
import { resolvePluginIcon } from "../icons";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import { PluginRuntimeBoundary } from "../runtime-boundary";
|
||||
@@ -25,9 +27,8 @@ import { createPluginSurfaceRuntime } from "../surface-runtime";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { resolvePluginWorkspacePanel } from "./resolution";
|
||||
|
||||
const EMPTY_THEME_DTO: Record<string, unknown> = {};
|
||||
const pluginThemeMapping = (theme: Theme) => ({
|
||||
themeDto: JSON.parse(JSON.stringify(theme)) as Record<string, unknown>,
|
||||
theme: toPluginTheme(theme),
|
||||
});
|
||||
|
||||
function resolvePlatform(): PluginHostProps["layout"]["platform"] {
|
||||
@@ -36,7 +37,7 @@ function resolvePlatform(): PluginHostProps["layout"]["platform"] {
|
||||
return "web";
|
||||
}
|
||||
|
||||
function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<string, unknown> }) {
|
||||
function PluginPanelBody({ theme }: { theme: PluginTheme }) {
|
||||
const { serverId, workspaceId, target } = usePaneContext();
|
||||
invariant(target.kind === "plugin", "PluginPanel requires plugin target");
|
||||
const plugin = useInstalledPlugin(serverId, target.pluginId);
|
||||
@@ -75,7 +76,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<str
|
||||
if (contribution.context === "workspace") {
|
||||
const props: PluginWorkspacePanelProps = {
|
||||
context: "workspace",
|
||||
theme: themeDto,
|
||||
theme,
|
||||
host,
|
||||
layout,
|
||||
workspaceId,
|
||||
@@ -86,7 +87,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record<str
|
||||
} else if (agentExists && target.context === "agent") {
|
||||
const props: PluginAgentPanelProps = {
|
||||
context: "agent",
|
||||
theme: themeDto,
|
||||
theme,
|
||||
host,
|
||||
layout,
|
||||
workspaceId,
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("plugin scaffold", () => {
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
path.join(directory, "inspect.shared.ts"),
|
||||
`import { defineRpc } from "@paseo/plugin";
|
||||
`import { defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const inspect = defineRpc({
|
||||
@@ -62,7 +62,7 @@ export const inspect = defineRpc({
|
||||
),
|
||||
writeFile(
|
||||
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 { inspect } from "./inspect.shared";
|
||||
|
||||
|
||||
@@ -2,13 +2,95 @@ import { mkdir, readdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { PluginIdSchema } from "@getpaseo/protocol/messages";
|
||||
|
||||
const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
|
||||
import type { ComponentType } from "react";
|
||||
const SDK_DECLARATIONS = `declare module "@paseo/plugin/server" {
|
||||
import type { PaseoApi } from "@getpaseo/client";
|
||||
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 {
|
||||
theme: Record<string, unknown>;
|
||||
theme: PluginTheme;
|
||||
host: { id: string; label: string };
|
||||
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: "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 {
|
||||
id: 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: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise<void> };
|
||||
|
||||
export interface PluginHandlerContext {
|
||||
paseo: PaseoApi;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
@@ -162,12 +208,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
|
||||
export type PluginCleanup = () => void | Promise<void>;
|
||||
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>(
|
||||
contract: PluginRpcContract<InputSchema, OutputSchema>,
|
||||
): (input: ZodInput<InputSchema>) => Promise<ZodOutput<OutputSchema>>;
|
||||
@@ -183,13 +223,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" {
|
||||
agentId: string,
|
||||
selector: (agent: PluginAgentSnapshot) => Selection,
|
||||
): 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";
|
||||
import { Text } from "react-native";
|
||||
const CLIENT_SURFACE = `import type { PluginSurfaceProps } from "@paseo/plugin";
|
||||
import React, { useMemo } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
export function MainSurface() {
|
||||
return <Text>Hello from my plugin</Text>;
|
||||
export function MainSurface({ theme, layout }: PluginSurfaceProps) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
"source": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.ts",
|
||||
"source": "./src/server.ts",
|
||||
"default": "./dist/server.js"
|
||||
},
|
||||
"./host": {
|
||||
"types": "./dist/host.d.ts",
|
||||
"source": "./src/host.ts",
|
||||
|
||||
@@ -3,8 +3,19 @@ import type { PaseoApi } from "@getpaseo/client";
|
||||
import type { ZodType, input as ZodInput, output as ZodOutput } from "zod";
|
||||
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 {
|
||||
theme: Record<string, unknown>;
|
||||
theme: PluginTheme;
|
||||
host: {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export {
|
||||
PluginAttachmentItemSchema,
|
||||
PluginAttachmentSearchPayloadSchema,
|
||||
defineAttachmentSource,
|
||||
defineRpc,
|
||||
type PluginAttachmentItem,
|
||||
type PluginAttachmentSearchPayload,
|
||||
} from "./attachments.js";
|
||||
type PluginRpcContract,
|
||||
} from "./server.js";
|
||||
export type {
|
||||
PluginAttachmentSourceContribution,
|
||||
PluginAgentCommandContext,
|
||||
@@ -17,6 +20,7 @@ export type {
|
||||
PluginGlobalCommandContext,
|
||||
PluginHandlerContext,
|
||||
PluginHostProps,
|
||||
PluginTheme,
|
||||
PluginSidebarContribution,
|
||||
PluginSurfaceContribution,
|
||||
PluginSurfaceProps,
|
||||
@@ -27,12 +31,4 @@ export type {
|
||||
} from "./contracts.js";
|
||||
export { usePaseo } from "./paseo-context.js";
|
||||
export { useAgent, useWorkspace } from "./client-state.js";
|
||||
import type { PluginAttachmentSourceContribution } from "./contracts.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;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,57 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
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";
|
||||
|
||||
@@ -228,6 +278,7 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin {
|
||||
}
|
||||
|
||||
async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise<string> {
|
||||
const { build } = await import("esbuild");
|
||||
const source = await readFile(entryPath, "utf8");
|
||||
const filteredSource = filterEntrypoint(source, target);
|
||||
const result = await build({
|
||||
@@ -245,13 +296,14 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom
|
||||
target === "client"
|
||||
? [
|
||||
"@paseo/plugin",
|
||||
"@paseo/plugin/server",
|
||||
"@tanstack/react-query",
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
"react-native",
|
||||
"zod",
|
||||
]
|
||||
: ["@paseo/plugin", "zod"],
|
||||
: ["@paseo/plugin", "@paseo/plugin/server", "zod"],
|
||||
plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)],
|
||||
logLevel: "silent",
|
||||
treeShaking: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
defineRpc,
|
||||
type PluginHandlerContext,
|
||||
type PluginRpcContract,
|
||||
} from "@paseo/plugin";
|
||||
} from "@paseo/plugin/server";
|
||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
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 });
|
||||
}
|
||||
|
||||
const pluginAuthorRuntime = { defineAttachmentSource, defineRpc };
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -597,7 +597,7 @@ export function Surface() {
|
||||
),
|
||||
writeFile(
|
||||
path.join(directory, "inspect.shared.ts"),
|
||||
`import { defineRpc } from "@paseo/plugin";
|
||||
`import { defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const inspectRpc = defineRpc({
|
||||
|
||||
@@ -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 { searchIssuesRpc } from "./issues.shared";
|
||||
import { createLinearIssueSearch } from "./linear";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
|
||||
import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
const SearchPayloadSchema = z.object({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineRpc } from "@paseo/plugin";
|
||||
import { defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const incrementRpc = defineRpc({
|
||||
|
||||
@@ -2,36 +2,28 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@paseo/plugin";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { z } from "zod";
|
||||
import { incrementRpc } from "./increment.shared";
|
||||
|
||||
const ExampleThemeSchema = z.object({
|
||||
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);
|
||||
export function ExamplePanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
|
||||
const workspace = useWorkspace(workspaceId, ({ name }) => ({ name }));
|
||||
const callIncrement = useRpc(incrementRpc);
|
||||
const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement });
|
||||
const value = data?.value ?? 0;
|
||||
const styles = useMemo(
|
||||
() => ({
|
||||
screen: { flex: 1, padding: 24, gap: 16, backgroundColor: colors.surface0 },
|
||||
title: { color: colors.foreground, fontSize: 24 },
|
||||
detail: { color: colors.foregroundMuted },
|
||||
button: { padding: 14, borderRadius: 10, backgroundColor: colors.accent },
|
||||
buttonText: { color: colors.accentForeground, textAlign: "center" as const },
|
||||
error: { color: colors.statusDanger },
|
||||
screen: {
|
||||
flex: 1,
|
||||
padding: layout.compact ? 16 : 24,
|
||||
gap: 16,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
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(() => {
|
||||
mutate({ value });
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
```tsx
|
||||
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 }) => ({
|
||||
name,
|
||||
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 (
|
||||
<View style={styles.screen}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: { flex: 1, padding: 24, gap: 8 },
|
||||
title: { fontSize: 24 },
|
||||
});
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -96,7 +110,7 @@ paseo plugin install /absolute/path/to/workspace-plugin
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
|
||||
@@ -58,6 +58,13 @@ my-plugin/
|
||||
| `*.server.ts` | Node APIs, local resources, credentials, and RPC handlers. |
|
||||
| `*.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.
|
||||
|
||||
## Entry point and cleanup
|
||||
@@ -84,10 +91,28 @@ Register a component, then point a sidebar item at its surface ID:
|
||||
|
||||
```tsx
|
||||
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) {
|
||||
return <Text>{`${host.label} · ${layout.platform}`}</Text>;
|
||||
export function Main({ theme, host, layout }: PluginSurfaceProps) {
|
||||
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:
|
||||
|
||||
| Field | Meaning |
|
||||
| -------- | --------------------------------------------------------- |
|
||||
| `theme` | Paseo theme values. Validate the keys your surface reads. |
|
||||
| `host` | Selected host `id` and display `label`. |
|
||||
| `layout` | `compact` and the `ios`, `android`, or `web` platform. |
|
||||
| Field | Meaning |
|
||||
| -------- | ------------------------------------------------------------ |
|
||||
| `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. |
|
||||
| `host` | Selected host `id` and display `label`. |
|
||||
| `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.
|
||||
|
||||
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
|
||||
|
||||
@@ -129,12 +172,30 @@ Register one panel for workspace or agent context:
|
||||
|
||||
```tsx
|
||||
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 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
|
||||
|
||||
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:
|
||||
|
||||
```tsx
|
||||
import { defineRpc } from "@paseo/plugin";
|
||||
import { defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
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`:
|
||||
|
||||
```ts
|
||||
import { defineRpc } from "@paseo/plugin";
|
||||
import { defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const greeting = defineRpc({
|
||||
@@ -411,7 +474,7 @@ An attachment source searches external resources and returns a stable text snaps
|
||||
`issues.shared.ts`:
|
||||
|
||||
```ts
|
||||
import { defineAttachmentSource, defineRpc } from "@paseo/plugin";
|
||||
import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server";
|
||||
import { z } from "zod";
|
||||
|
||||
export const searchIssues = defineRpc({
|
||||
|
||||
@@ -62,16 +62,34 @@ Cleanup can be async. Use it for timers, watchers, sockets, and other resources
|
||||
|
||||
## Add a workspace panel
|
||||
|
||||
Workspace panels live beside agents, terminals, files, and diffs. Their props contain stable IDs;
|
||||
required-selector hooks read cached client state without subscribing to unrelated fields:
|
||||
Workspace panels live beside agents, terminals, files, and diffs. Plugins run on desktop and
|
||||
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
|
||||
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);
|
||||
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) {
|
||||
@@ -102,24 +120,39 @@ the active workspace or agent. Command callbacks receive the selected host's `pa
|
||||
|
||||
## 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
|
||||
import type { PluginContext } from "@paseo/plugin";
|
||||
import React, { useState } from "react";
|
||||
import type { PluginContext, PluginSurfaceProps } from "@paseo/plugin";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
|
||||
function Counter() {
|
||||
function Counter({ theme, layout }: PluginSurfaceProps) {
|
||||
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 (
|
||||
<View style={{ flex: 1, padding: 24, gap: 16 }}>
|
||||
<Text style={{ fontSize: 48 }}>{count}</Text>
|
||||
<View style={styles.screen}>
|
||||
<Text style={styles.count}>{count}</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={`Increment counter, currently ${count}`}
|
||||
onPress={() => setCount((value) => value + 1)}
|
||||
style={styles.button}
|
||||
>
|
||||
<Text>Count me in</Text>
|
||||
<Text style={styles.buttonText}>Count me in</Text>
|
||||
</Pressable>
|
||||
</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
|
||||
|
||||
@@ -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()`:
|
||||
|
||||
```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";
|
||||
|
||||
const greeting = defineRpc({
|
||||
@@ -235,7 +275,8 @@ daemon log. Never log credentials or other secrets.
|
||||
Define a search RPC and register a declarative source:
|
||||
|
||||
```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";
|
||||
|
||||
const searchIssues = defineRpc({
|
||||
@@ -328,7 +369,7 @@ After a change:
|
||||
1. Run `npm run typecheck`.
|
||||
2. Install or reload the exact runtime ID.
|
||||
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.
|
||||
|
||||
Common failures:
|
||||
|
||||
Reference in New Issue
Block a user