Let plugins contribute settings screens (#4357)

* feat(plugins): contribute settings screens and shared controls

* fix(plugins): localize settings and verify shared UI consumers

* test(composer): unmount draft test roots after each case

* test(app): keep launch terminals alive until cleanup

The fixed ten-second profile lifetime could expire before a busy CI browser attached or finished revisiting the composer. Keep the real process waiting for input until project teardown terminates it.

* test(server): await the initial snapshot before a buffered ref event

Watcher setup can read checkout facts before the initial refresh publishes a snapshot. Wait for publication before injecting the event so the test consistently exercises a second refresh.
This commit is contained in:
Mohamed Boudra
2026-09-06 10:22:26 +02:00
committed by GitHub
parent ab7d66dfed
commit ae80ec4252
94 changed files with 1890 additions and 295 deletions
+1 -1
View File
@@ -120,7 +120,7 @@ Workspace and chat surfaces use the full width — these are working surfaces, n
Sections sit apart. `<SettingsSection>` owns its own bottom margin; the next thing is wrapped in another `<SettingsSection>`. The agent-list `sectionHeading` carries the same `marginTop`/`marginBottom` rhythm (`packages/app/src/components/agent-list.tsx:511-517`). Adding `marginBottom` to a section is wrong.
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/screens/settings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
A section or group explains itself through the `info` prop on `<SettingsSection>` or `<SettingsGroup>` — an info icon beside the header that opens a tooltip (`packages/app/src/components/settings/headings/settings-info-tip.tsx`). A muted paragraph between the header and the card is wrong: it sits in the section's own gap, so it reads as a second heading rather than as prose belonging to the header. Explanatory copy that describes one row belongs to that row, as `settingsStyles.rowHint` inside the card.
Cards inside a section sit closer than sections. Rows inside a card touch — only the divider separates them. The rhythm is page → spacious; section → spacious; card → tight.
+3
View File
@@ -77,6 +77,9 @@ only use local param fallback during cold mount (`/` or empty pathname), or a
hidden workspace can overwrite the remembered workspace before Settings or
History returns.
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
Back returns to that host's Plugins page.
Settings detail routes are separate siblings on purpose. Keep
`settings/[section]`, the host routes, the projects index, and project detail as
distinct route names. `router.dismissTo()` ultimately matches stack entries by
+8
View File
@@ -78,6 +78,14 @@ complete`), keyed off the opened snapshot's serverId. Waiting for data is a
timezone) once — never in a preview line AND a helper line.
- `useUnistyles` is banned (see docs/unistyles.md); lint enforces.
## Settings rows
Use the named components from `@/components/settings` for settings cards and controls.
`SettingsCard` owns row separators; `SettingsRow` accepts custom content. Heading-only screens
can import `SettingsGroup` and `SettingsSection` directly from their modules in
`@/components/settings/headings/`.
The plugin UI entry exposes the same components; keep them independent of storage and routing.
## Data gating
Aggregate hooks return a discriminated load state:
+13 -1
View File
@@ -1,7 +1,7 @@
# Plugins
Local plugins contribute daemon RPCs, native app surfaces, workspace panels, Command Center items,
client slash commands, timeline items, composer pills, app themes, and composer attachment sources.
client slash commands, timeline items, composer pills, app themes, composer attachment sources, and settings screens.
Paseo executes `index.server.ts` in a subprocess and `index.client.tsx` in every connected app.
> **Trust every plugin you add.** `paseo plugin add` and `paseo plugin install` mean “I trust this codebase.” Plugins are unsandboxed: server code and Git preparation commands run with the daemon user's access on the daemon host, and client contributions run inside Paseo. The repository's dependencies and future updates are part of that trust decision. With `--host`, preparation runs on that remote daemon host.
@@ -440,6 +440,18 @@ on several hosts are not coalesced. The selected snapshot submits as a text atta
external-resource presentation, so it remains readable if the plugin is removed or an older peer
drops the optional presentation fields.
## Contribute settings
Register ordinary components with `client.addSettingsScreen` and open them with `openSettings`.
The host settings shell owns navigation and layout; plugin content must not add another page
scroll view or header. See the [author contract](../public-docs/plugins/v0.8/reference.md#settings-screens)
and `plugin-examples/settings` for the named UI components and persistence API.
Settings storage is scoped to the runtime installation ID, never the source path or manifest ID.
Its writer lives with the plugin subprocess, while its directory lives outside managed sources,
so updates and reloads retain values. Settings-change notifications must not enter the catalog
reload path: that path disposes the plugin and would destroy open drafts after every save.
## Contribute a theme
`addTheme` takes a small light or dark palette and a display name. Paseo expands it through the
@@ -21,13 +21,13 @@ import {
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// `sleep` keeps the terminal alive long enough for the UI to attach and
// render output before the process exits — see new-workspace-launch-terminal.spec.ts.
// Wait for input until project cleanup kills the terminal. A fixed lifetime
// can expire before a busy browser attaches or finishes revisiting the composer.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-memory-profile",
name: "Memory Profile",
command: "/bin/sh",
args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"],
args: ["-c", 'echo remembered: "$0"; exec cat', "{{{prompt}}}"],
};
function hasLeftNewWorkspaceRoute(url: URL): boolean {
@@ -25,14 +25,13 @@ import {
//
// The sentinel becomes `$0` (a positional shell parameter, not string
// interpolation into the script source), so the prompt reaches the process
// exactly as typed with no injection risk. `sleep` keeps the terminal alive
// long enough for the UI to attach and render the output before it exits —
// a bare `echo` exits before the frontend subscribes to its output.
// exactly as typed with no injection risk. `cat` waits for input until project
// cleanup kills the terminal, so attachment does not race a fixed process lifetime.
const PROMPT_PROFILE: TerminalProfile = {
id: "e2e-echo-prompt",
name: "Echo Prompt",
command: "/bin/sh",
args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"],
args: ["-c", 'echo captured: "$0"; exec cat', "{{{prompt}}}"],
};
// No sentinel anywhere: takes no prompt, composer goes read-only.
@@ -40,7 +39,7 @@ const BARE_PROFILE: TerminalProfile = {
id: "e2e-echo-bare",
name: "Bare Echo",
command: "/bin/sh",
args: ["-c", "echo bare-launch-static-line; sleep 10"],
args: ["-c", "echo bare-launch-static-line; exec cat"],
};
test.describe("New workspace: launching a terminal", () => {
@@ -88,7 +87,7 @@ test.describe("New workspace: launching a terminal", () => {
});
await selectLaunchOption(page, BARE_PROFILE.id);
await expect(terminalPromptInput(page)).toHaveCount(0);
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10");
await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; exec cat");
await expect(terminalLaunchSubmit(page)).toHaveText("Launch");
});
@@ -0,0 +1,62 @@
import {
test,
openDisplaySettings,
openCompactDisplaySettings,
groupByWorkspace,
hideMetadata,
reloadSavedDisplaySettings,
editTitle,
replaceTitle,
saveTitle,
expectTitleRequired,
expectSavedTitle,
changeMetadataFromAnotherClient,
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
test("display preferences survive a reload", async ({ page }) => {
await openDisplaySettings(page);
await groupByWorkspace(page);
await hideMetadata(page);
await reloadSavedDisplaySettings(page);
});
test("invalid titles stay editable until a valid title is saved", async ({ page }) => {
await openDisplaySettings(page);
await editTitle(page, "");
await saveTitle(page);
await expectTitleRequired(page);
await replaceTitle(page, "My monitor");
await saveTitle(page);
await expectSavedTitle(page, "My monitor");
});
test("another client's save preserves a conflicting draft", async ({ page }) => {
await openDisplaySettings(page);
await editTitle(page, "Unsaved title");
await changeMetadataFromAnotherClient(page);
await saveTitle(page);
await expectConflictPreservesDraft(page, "Unsaved title");
await discardTitle(page);
});
test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("Back and plugin re-enable restore the settings screen", async ({
page,
settingsPlugin,
}) => {
await openCompactDisplaySettings(page);
await groupByWorkspace(page);
await hideMetadata(page);
await returnAndReopenSettings(page);
await settingsPlugin.disablePlugin("settings-example");
await expectSettingsUnavailable(page);
await settingsPlugin.enablePlugin("settings-example");
await reloadSavedDisplaySettings(page);
});
});
@@ -20,6 +20,8 @@ type NewWorkspaceDaemonClient = Pick<
| "getPaseoWorktreeList"
| "getDaemonConfig"
| "installDirectoryPlugin"
| "disablePlugin"
| "enablePlugin"
| "inspectWorkspaceRecovery"
| "listProjects"
| "on"
@@ -0,0 +1,108 @@
import path from "node:path";
import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
const directory = path.resolve(__dirname, "../../../../../plugin-examples/settings");
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
settingsPlugin: [
async ({ e2eWorker, page }, provide, testInfo) => {
void e2eWorker;
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await client.getDaemonConfig();
try {
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
await provide(client);
await page.screenshot({ path: testInfo.outputPath("settings.png") });
} finally {
await client.removePlugin("settings-example");
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
await client.close();
}
},
{ auto: true },
],
});
export async function openDisplaySettings(page: Page) {
await gotoAppShell(page);
await openSettings(page);
await openPluginScreen(page);
}
export async function openCompactDisplaySettings(page: Page) {
await gotoAppShell(page);
await openCompactSettings(page, new URL(page.url()).pathname);
await openPluginScreen(page);
}
async function openPluginScreen(page: Page) {
await openHostSection(page, getServerId(), "plugins");
await page.getByRole("button", { name: "Open", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function groupByWorkspace(page: Page) {
await page.getByRole("button", { name: "Group agents by", exact: true }).click();
await page.getByRole("menuitem", { name: "Workspace", exact: true }).click();
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
}
export async function hideMetadata(page: Page) {
await page.getByRole("switch", { name: "Show metadata" }).click();
await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible();
}
export async function reloadSavedDisplaySettings(page: Page) {
await page.reload();
await expect(page.getByText("Grouped by workspace", { exact: true })).toBeVisible();
await expect(page.getByRole("switch", { name: "Show metadata" })).not.toBeChecked();
}
export async function editTitle(page: Page, title: string) {
await page.getByRole("button", { name: "Edit title", exact: true }).click();
await replaceTitle(page, title);
}
export async function replaceTitle(page: Page, title: string) {
await page.getByRole("textbox", { name: "Monitor title", exact: true }).fill(title);
}
export async function saveTitle(page: Page) {
await page.getByRole("button", { name: "Save title", exact: true }).click();
}
export async function expectTitleRequired(page: Page) {
await expect(page.getByText(/Enter a title/).first()).toBeVisible();
}
export async function expectSavedTitle(page: Page, title: string) {
await expect(page.getByText(title, { exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveCount(0);
}
export async function changeMetadataFromAnotherClient(page: Page) {
const peer = await page.context().newPage();
try {
await peer.goto(page.url());
await hideMetadata(peer);
await expect(page.getByText("Metadata hidden", { exact: true })).toBeVisible();
} finally {
await peer.close();
}
}
export async function expectConflictPreservesDraft(page: Page, title: string) {
await expect(page.getByText(/Settings changed on another client/)).toBeVisible();
await expect(page.getByRole("textbox", { name: "Monitor title", exact: true })).toHaveValue(
title,
);
}
export async function discardTitle(page: Page) {
await page.getByRole("button", { name: "Discard", exact: true }).click();
await expectSavedTitle(page, "Agent monitor");
}
export async function returnAndReopenSettings(page: Page) {
await goBackInSettings(page);
await expect(page.getByRole("button", { name: "Open", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Open", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
).toBeVisible();
}
@@ -7,7 +7,7 @@ import type { AgentProfile } from "@getpaseo/protocol/messages";
import { Button } from "@/components/ui/button";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
+1 -1
View File
@@ -7,7 +7,7 @@ import { ArrowUpRight, Blocks, Check, Settings2 } from "lucide-react-native";
import type { AgentSkillOperation, AgentSkillsStatus } from "@getpaseo/protocol/messages";
import type { TFunction } from "i18next";
import { Button } from "@/components/ui/button";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -6,7 +6,7 @@ import { Check } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import type { Theme } from "@/styles/theme";
import { confirmDialog } from "@/utils/confirm-dialog";
+1
View File
@@ -902,6 +902,7 @@ function RootStack() {
<Stack.Screen name="h/[serverId]" />
<Stack.Screen name="settings/hosts/[serverId]/index" />
<Stack.Screen name="settings/hosts/[serverId]/[hostSection]" />
<Stack.Screen name="settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" />
<Stack.Screen name="settings/hosts/[serverId]/projects/index" />
<Stack.Screen name="settings/hosts/[serverId]/projects/[projectId]" />
</ThemedStack>
@@ -0,0 +1,22 @@
import { useMemo } from "react";
import { useLocalSearchParams } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import SettingsScreen from "@/screens/settings-screen";
export default function PluginSettingsRoute() {
const { serverId, pluginId, screenId } = useLocalSearchParams<{
serverId: string;
pluginId: string;
screenId: string;
}>();
const view = useMemo(
() => ({ kind: "plugin" as const, serverId, pluginId, screenId }),
[serverId, pluginId, screenId],
);
return (
<HostRouteBootstrapBoundary>
<SettingsScreen view={view} />
</HostRouteBootstrapBoundary>
);
}
@@ -1,7 +1,7 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SettingsInfoTip } from "@/screens/settings/settings-info-tip";
import { SettingsInfoTip } from "./settings-info-tip";
interface SettingsGroupProps {
title: string;
@@ -1,7 +1,7 @@
import { useMemo, type ReactNode } from "react";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { SettingsInfoTip } from "@/screens/settings/settings-info-tip";
import { SettingsInfoTip } from "./settings-info-tip";
import { settingsStyles } from "@/styles/settings";
interface SettingsSectionProps {
@@ -0,0 +1,182 @@
import {
Children,
isValidElement,
useCallback,
useImperativeHandle,
useMemo,
useRef,
type ReactNode,
} from "react";
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type {
SettingsRowProps,
SettingsSwitchProps,
SettingsSelectProps,
SettingsInputProps,
SettingsActionProps,
} from "@getpaseo/plugin/ui";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { FormTextInput } from "@/components/ui/form-field";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { useIsCompactFormFactor } from "@/constants/layout";
import { settingsStyles } from "@/styles/settings";
export { SettingsGroup } from "./headings/settings-group";
export { SettingsSection } from "./headings/settings-section";
export function SettingsCard({ children, testID }: { children: ReactNode; testID?: string }) {
return (
<View style={settingsStyles.card} testID={testID}>
{Children.toArray(children).map((child, index) => (
<View
key={isValidElement(child) ? child.key : index}
style={index ? settingsStyles.rowBorder : undefined}
>
{child}
</View>
))}
</View>
);
}
export function SettingsRow({ label, hint, error, children, testID }: SettingsRowProps) {
const compact = useIsCompactFormFactor();
const rowStyle = useMemo(() => [settingsStyles.row, compact && styles.compactRow], [compact]);
return (
<View style={rowStyle} testID={testID}>
<View style={styles.label}>
<Text style={settingsStyles.rowTitle}>{label}</Text>
{hint ? <Text style={settingsStyles.rowHint}>{hint}</Text> : null}
{error ? (
<Text accessibilityRole="alert" style={settingsStyles.rowError}>
{error}
</Text>
) : null}
</View>
{children ? <View style={styles.control}>{children}</View> : null}
</View>
);
}
export function SettingsSwitch({ value, onValueChange, disabled, ...row }: SettingsSwitchProps) {
return (
<SettingsRow {...row}>
<Switch
value={value}
onValueChange={onValueChange}
disabled={disabled}
accessibilityLabel={row.label}
/>
</SettingsRow>
);
}
function SettingsOption<Value extends string>({
option,
selected,
onValueChange,
}: {
option: { label: string; value: Value };
selected: boolean;
onValueChange(value: Value): void;
}) {
const select = useCallback(() => onValueChange(option.value), [onValueChange, option.value]);
return (
<DropdownMenuItem selected={selected} onSelect={select}>
{option.label}
</DropdownMenuItem>
);
}
export function SettingsSelect<Value extends string>({
value,
options,
onValueChange,
disabled,
...row
}: SettingsSelectProps<Value>) {
return (
<SettingsRow {...row}>
<DropdownMenu>
<DropdownTrigger
disabled={disabled}
accessibilityRole="button"
accessibilityLabel={row.label}
>
<Text style={styles.value}>
{options.find((option) => option.value === value)?.label ?? value}
</Text>
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={220}>
{options.map((option) => (
<SettingsOption
key={option.value}
option={option}
selected={option.value === value}
onValueChange={onValueChange}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
</SettingsRow>
);
}
export function SettingsInput({
initialValue,
onChangeText,
placeholder,
disabled,
secureTextEntry,
ref,
...row
}: SettingsInputProps) {
const compact = useIsCompactFormFactor();
const input = useRef<EditingTextInputHandle>(null);
useImperativeHandle(
ref,
() => ({
focus: () => input.current?.focus(),
blur: () => input.current?.blur(),
getText: () => input.current?.getText() ?? "",
replaceText: (text) => input.current?.replaceText(text),
}),
[],
);
return (
<SettingsRow {...row}>
<FormTextInput
ref={input}
initialValue={initialValue}
onChangeText={onChangeText}
placeholder={placeholder}
editable={!disabled}
secureTextEntry={secureTextEntry}
accessibilityLabel={row.label}
size={compact ? "md" : "sm"}
style={styles.input}
/>
</SettingsRow>
);
}
export function SettingsAction({ actionLabel, onPress, disabled, ...row }: SettingsActionProps) {
return (
<SettingsRow {...row}>
<Button variant="outline" size="sm" onPress={onPress} disabled={disabled}>
{actionLabel}
</Button>
</SettingsRow>
);
}
const styles = StyleSheet.create((theme) => ({
compactRow: { flexWrap: "wrap", gap: theme.spacing[3] },
label: { flexGrow: 1, flexShrink: 1, flexBasis: 160, marginRight: theme.spacing[3] },
control: { flexShrink: 1, maxWidth: "100%" },
value: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
input: { minWidth: 180 },
}));
@@ -1,8 +1,8 @@
/** @vitest-environment jsdom */
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useDraftStore } from "@/stores/draft-store";
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
@@ -116,26 +116,24 @@ vi.mock("@/hooks/use-agent-form-state", () => ({
}),
}));
const mountedRoots = new Set<Root>();
function createTestRoot(container: HTMLElement): Root {
const root = createRoot(container);
mountedRoots.add(root);
return root;
}
afterEach(async () => {
await act(async () => {
for (const root of mountedRoots) root.unmount();
mountedRoots.clear();
});
});
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
beforeAll(async () => {
const storage = new Map<string, string>();
Object.defineProperty(globalThis, "window", {
value: {
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
},
},
configurable: true,
});
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
value: true,
configurable: true,
@@ -147,18 +145,8 @@ beforeAll(async () => {
describe("useAgentInputDraft live contract", () => {
beforeEach(() => {
asyncStorage.clear();
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
url: "http://localhost",
});
Object.defineProperty(globalThis, "document", {
value: dom.window.document,
configurable: true,
});
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
});
document.body.innerHTML = "<div id='root'></div>";
localStorage.clear();
useDraftStore.setState({
drafts: {},
@@ -206,7 +194,7 @@ describe("useAgentInputDraft live contract", () => {
throw new Error("Missing root container");
}
let root: Root | null = createRoot(container);
let root: Root | null = createTestRoot(container);
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
@@ -248,7 +236,7 @@ describe("useAgentInputDraft live contract", () => {
root!.unmount();
});
root = createRoot(container);
root = createTestRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -306,7 +294,7 @@ describe("useAgentInputDraft live contract", () => {
throw new Error("Missing root container");
}
const root = createRoot(container);
const root = createTestRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -372,7 +360,7 @@ describe("useAgentInputDraft live contract", () => {
throw new Error("Missing root container");
}
const root = createRoot(container);
const root = createTestRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -423,7 +411,7 @@ describe("useAgentInputDraft live contract", () => {
throw new Error("Missing root container");
}
const root = createRoot(container);
const root = createTestRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -438,9 +426,16 @@ describe("useAgentInputDraft live contract", () => {
});
expect(getLatest().attachments).toEqual([{ kind: "image", metadata: image }]);
expect(useDraftStore.getState().drafts["draft:attachments"]?.input).toEqual({
text: "with attachment",
attachments: [{ kind: "image", metadata: image }],
const readPersistedInput = () => useDraftStore.getState().drafts["draft:attachments"]?.input;
await act(async () => {
// Web text publication occurs after paint; attachments save immediately.
await expect.poll(readPersistedInput).toEqual({
text: "with attachment",
attachments: [{ kind: "image", metadata: image }],
});
});
await act(async () => {
root.unmount();
});
});
@@ -505,7 +500,7 @@ describe("useAgentInputDraft live contract", () => {
throw new Error("Missing root container");
}
const root = createRoot(container);
const root = createTestRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -559,7 +554,7 @@ describe("useAgentInputDraft live contract", () => {
throw new Error("Missing root container");
}
const root = createRoot(container);
const root = createTestRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useToast } from "@/contexts/toast-context";
import { getDesktopHost } from "@/desktop/host";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useBrowserStore } from "@/desktop/browser/store";
import { settingsStyles } from "@/styles/settings";
import { confirmDialog } from "@/utils/confirm-dialog";
@@ -9,7 +9,7 @@ import { Switch } from "@/components/ui/switch";
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({
@@ -7,7 +7,7 @@ import { Button } from "@/components/ui/button";
import { DesktopPermissionRow } from "@/desktop/components/desktop-permission-row";
import { useDesktopPermissions } from "@/desktop/permissions/use-desktop-permissions";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
const ThemedRotateCw = withUnistyles(RotateCw, (theme) => ({
size: theme.iconSize.md,
@@ -5,7 +5,7 @@ import * as Clipboard from "expo-clipboard";
import { useTranslation } from "react-i18next";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { ArrowUpRight, Copy, FileText, Activity } from "lucide-react-native";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -6,7 +6,7 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowUpRight, Check, Terminal } from "lucide-react-native";
import { Button } from "@/components/ui/button";
import { useCliInstall } from "@/desktop/hooks/use-install-status";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { openExternalUrl } from "@/utils/open-external-url";
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
+6
View File
@@ -1999,6 +1999,12 @@ export const en = {
},
plugins: {
title: "Plugins",
screens: {
open: "Open",
offline: "Connect to this host to open plugin settings.",
update: "Update this host to use plugin settings.",
unavailable: "This plugin settings screen is unavailable.",
},
trustedTitle: "Plugins are trusted code",
trustedDescription:
"They run on this host and inside the app without sandboxing. Install only code you trust.",
@@ -14,6 +14,12 @@ const shared = {
export const pluginSettings = {
ar: {
...shared,
screens: {
open: "فتح",
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
},
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
trustedDescription:
@@ -61,6 +67,12 @@ export const pluginSettings = {
},
es: {
...shared,
screens: {
open: "Abrir",
offline: "Conéctate a este host para abrir los ajustes del plugin.",
update: "Actualiza este host para usar los ajustes del plugin.",
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
},
title: "Plugins",
trustedTitle: "Los plugins son código de confianza",
trustedDescription:
@@ -108,6 +120,12 @@ export const pluginSettings = {
},
fr: {
...shared,
screens: {
open: "Ouvrir",
offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.",
update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.",
unavailable: "Cet écran de paramètres du plugin est indisponible.",
},
title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance",
trustedDescription:
@@ -155,6 +173,12 @@ export const pluginSettings = {
},
ja: {
...shared,
screens: {
open: "開く",
offline: "このホストに接続してプラグイン設定を開いてください。",
update: "プラグイン設定を使用するには、このホストを更新してください。",
unavailable: "このプラグイン設定画面は利用できません。",
},
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
trustedDescription:
@@ -202,6 +226,12 @@ export const pluginSettings = {
},
ko: {
...shared,
screens: {
open: "열기",
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
},
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
trustedDescription: "호스트와 앱 안에서 격리 없이 실행됩니다. 신뢰하는 코드만 설치하세요.",
@@ -248,6 +278,12 @@ export const pluginSettings = {
},
"pt-BR": {
...shared,
screens: {
open: "Abrir",
offline: "Conecte-se a este host para abrir as configurações do plugin.",
update: "Atualize este host para usar as configurações do plugin.",
unavailable: "Esta tela de configurações do plugin está indisponível.",
},
title: "Plugins",
trustedTitle: "Plugins são código confiável",
trustedDescription:
@@ -295,6 +331,12 @@ export const pluginSettings = {
},
ru: {
...shared,
screens: {
open: "Открыть",
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
update: "Обновите этот хост, чтобы использовать настройки плагина.",
unavailable: "Этот экран настроек плагина недоступен.",
},
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
trustedDescription:
@@ -342,6 +384,12 @@ export const pluginSettings = {
},
"zh-CN": {
...shared,
screens: {
open: "打开",
offline: "连接到此主机以打开插件设置。",
update: "更新此主机以使用插件设置。",
unavailable: "此插件设置页面不可用。",
},
title: "插件",
trustedTitle: "插件是受信任的代码",
trustedDescription: "它们会在此主机和应用内运行,且没有隔离。请仅安装你信任的代码。",
@@ -11,6 +11,7 @@ import {
} from "@/utils/host-routes";
export type SettingsView =
| { kind: "plugin"; serverId: string; pluginId: string; screenId: string }
| { kind: "root" }
| { kind: "section"; section: SettingsSectionSlug }
| { kind: "host"; serverId: string; section: HostSectionSlug }
@@ -32,7 +33,8 @@ export function returnFromSettings(view: SettingsView): void {
return;
}
const parent =
view.kind === "project" ? buildProjectsSettingsRoute(view.serverId) : buildSettingsRoute();
let parent: Href = buildSettingsRoute();
if (view.kind === "plugin") parent = buildSettingsHostSectionRoute(view.serverId, "plugins");
if (view.kind === "project") parent = buildProjectsSettingsRoute(view.serverId);
router.dismissTo(parent as Href);
}
+6
View File
@@ -11,6 +11,7 @@ import type { PluginSurfaceRuntime } from "./surface-runtime";
import type { InstalledPlugin } from "./types";
export interface PluginNavigation {
openSettings(pluginId: string, screenId: string): void;
openSurface(pluginId: string, surfaceId: string): void;
openWorkspacePanel(pluginId: string, panelId: string, location: PluginPanelLocation): void;
openAgentPanel(
@@ -29,6 +30,11 @@ export function createPluginCapabilities(
return {
paseo: runtime.paseo,
rpc: (contract, input) => callPluginRpc(contract, runtime.invoke, input),
openSettings(screenId) {
if (!plugin.settingsScreens.some((screen) => screen.id === screenId))
throw new Error(`Plugin settings screen is unavailable: ${screenId}`);
navigation.openSettings(plugin.id, screenId);
},
openSurface(surfaceId) {
if (!plugin.surfaces.some((surface) => surface.id === surfaceId)) {
throw new Error(`Plugin surface is unavailable: ${surfaceId}`);
+10
View File
@@ -1,3 +1,4 @@
import { pluginSettingsKey } from "./settings/use-settings";
import { useEffect } from "react";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useHostFeature } from "@/runtime/host-features";
@@ -49,6 +50,15 @@ export function PluginCatalogSync({
};
void refresh();
const unsubscribe = client.on("status", (message) => {
if (message.payload.status === "plugin_settings_changed") {
const { pluginId, settingsId } = message.payload;
if (typeof settingsId === "string") {
const plugin = pluginRegistry
.getSnapshot()
.find((item) => item.serverId === serverId && item.id === pluginId);
void plugin?.queryClient.invalidateQueries({ queryKey: pluginSettingsKey(settingsId) });
}
}
if (message.payload.status === "plugin_catalog_changed") {
const pluginId = message.payload.pluginId;
if (typeof pluginId === "string") void refresh(pluginId);
@@ -17,6 +17,7 @@ function installation(): InstalledPlugin {
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
surfaces: [],
sidebarItems: [],
workspacePanels: [],
@@ -62,6 +62,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin {
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => {},
settingsScreens: [],
surfaces: [{ id: "main", Component: () => null }],
sidebarItems: [],
workspacePanels: [
@@ -143,6 +144,7 @@ describe("plugin Command Center contributions", () => {
runtime: createRuntime,
state: stateSource(),
navigation: {
openSettings() {},
openSurface() {},
openWorkspacePanel() {},
openAgentPanel() {},
@@ -193,6 +195,7 @@ describe("plugin Command Center contributions", () => {
workspaceId: workspace.id,
agentId: agent.id,
navigation: {
openSettings() {},
openSurface(pluginId, surfaceId) {
opened.push(`${pluginId}/surface/${surfaceId}`);
},
@@ -224,6 +227,7 @@ describe("plugin Command Center contributions", () => {
workspaceId: workspace.id,
agentId: agent.id,
navigation: {
openSettings() {},
openSurface() {},
openWorkspacePanel() {},
openAgentPanel() {},
+16 -1
View File
@@ -4,6 +4,7 @@ import { runPluginClientBundle, type PluginClientRuntime } from "./evaluate";
const runtime = {
paseo: {},
async rpc() {},
openSettings() {},
openSurface() {},
openPanel() {},
addComposerPill() {
@@ -16,7 +17,7 @@ function evaluatePluginClientBundle(id: string, source: string) {
}
function bundle(body: string): string {
return `(function() {
return `(function(require) {
const module = { exports: {} };
module.exports.default = function(plugin) { ${body}; return function() {}; };
return module.exports;
@@ -24,6 +25,17 @@ function bundle(body: string): string {
}
describe("evaluatePluginClientBundle", () => {
it("accepts memoized settings screens", () => {
const plugin = evaluatePluginClientBundle(
"settings",
bundle(`
const Component = require("react").memo(function Settings() { return null; });
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component });
`),
);
expect(plugin.settingsScreens.map((screen) => screen.id)).toEqual(["display"]);
});
it("returns idempotent removers for every client registration", () => {
let pillCount = 0;
const plugin = runPluginClientBundle(
@@ -33,6 +45,7 @@ describe("evaluatePluginClientBundle", () => {
const schema = { safeParse(value) { return { success: true, data: value }; } };
globalThis.__pluginRemovals = [
plugin.addSurface("main", Component),
plugin.addSettingsScreen({ id: "display", title: "Display", icon: "Settings", Component }),
plugin.addSidebarItem({ id: "main", title: "Main", icon: "Blocks", surface: "main" }),
plugin.addWorkspacePanel({ id: "panel", title: "Panel", icon: "Blocks", context: "workspace", Component }),
plugin.addCommandCenterItem({ id: "command", title: "Command", icon: "Blocks", context: "global", onSelect() {} }),
@@ -59,6 +72,7 @@ describe("evaluatePluginClientBundle", () => {
expect(
[
plugin.surfaces,
plugin.settingsScreens,
plugin.sidebarItems,
plugin.workspacePanels,
plugin.commandCenterItems,
@@ -77,6 +91,7 @@ describe("evaluatePluginClientBundle", () => {
expect(
[
plugin.surfaces,
plugin.settingsScreens,
plugin.sidebarItems,
plugin.workspacePanels,
plugin.commandCenterItems,
+21 -1
View File
@@ -1,3 +1,6 @@
import * as pluginUiRuntime from "./react-native/ui";
import { useSettings } from "./settings/use-settings";
import { defineSettings } from "@getpaseo/plugin";
import * as React from "react";
import * as ReactJsxRuntime from "react/jsx-runtime";
// eslint-disable-next-line no-restricted-imports -- plugin client runtime injects host ReactNative.
@@ -70,7 +73,7 @@ function requireId(value: string, label: string): string {
export type PluginClientRuntime = Pick<
PluginClientContext,
"paseo" | "rpc" | "openSurface" | "openPanel" | "addComposerPill"
"paseo" | "rpc" | "openSettings" | "openSurface" | "openPanel" | "addComposerPill"
>;
export function runPluginClientBundle(
@@ -81,6 +84,7 @@ export function runPluginClientBundle(
): EvaluatedPlugin {
const collector: Omit<EvaluatedPlugin, "id" | "cleanup"> = {
surfaces: [],
settingsScreens: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
@@ -91,6 +95,7 @@ export function runPluginClientBundle(
timelineRenderers: [],
};
const surfaceIds = new Set<string>();
const settingsScreenIds = new Set<string>();
const sidebarItemIds = new Set<string>();
const workspacePanelIds = new Set<string>();
const commandCenterItemIds = new Set<string>();
@@ -122,6 +127,17 @@ export function runPluginClientBundle(
}
const pluginContext: PluginClientContext = {
...runtime,
addSettingsScreen(contribution) {
const screenId = requireId(contribution.id, "settings screen id");
if (settingsScreenIds.has(screenId))
throw new Error(`Duplicate settings screen: ${screenId}`);
if (!contribution.title.trim()) throw new Error(`Invalid settings screen: ${screenId}`);
resolvePluginIcon(contribution.icon);
settingsScreenIds.add(screenId);
return register(collector.settingsScreens, { ...contribution, id: screenId }, () =>
settingsScreenIds.delete(screenId),
);
},
addSurface(surfaceId: string, Component: ComponentType<PluginSurfaceProps>) {
const normalizedId = requireId(surfaceId, "surface id");
if (surfaceIds.has(normalizedId)) throw new Error(`Duplicate surface: ${normalizedId}`);
@@ -336,6 +352,7 @@ export function runPluginClientBundle(
},
};
const runtimeRequire = (name: string): unknown => {
if (name === "@getpaseo/plugin/ui") return pluginUiRuntime;
if (name === "react") return React;
if (name === "react/jsx-runtime") return ReactJsxRuntime;
if (name === "react-native") return ReactNative;
@@ -343,6 +360,8 @@ export function runPluginClientBundle(
return {
defineAttachmentSource,
defineRpc,
defineSettings,
useSettings,
Icon,
usePaseo,
useAgent,
@@ -406,6 +425,7 @@ export function runPluginClientBundle(
id,
cleanup,
surfaces: collector.surfaces,
settingsScreens: collector.settingsScreens,
sidebarItems: collector.sidebarItems,
workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"],
commandCenterItems: collector.commandCenterItems,
+4
View File
@@ -1,3 +1,4 @@
import { buildPluginSettingsRoute } from "./settings/routes";
import { router } from "expo-router";
import type { PluginPanelLocation } from "@getpaseo/plugin";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
@@ -19,6 +20,9 @@ export function createPluginNavigation(input: {
return { mode: "pane" as const, paneId };
}
return {
openSettings(pluginId, screenId) {
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
},
openSurface(pluginId, surfaceId) {
router.push(buildPluginSurfaceRoute(serverId, pluginId, { kind: "surface", id: surfaceId }));
},
@@ -0,0 +1,10 @@
export {
SettingsGroup,
SettingsSection,
SettingsCard,
SettingsRow,
SettingsSwitch,
SettingsSelect,
SettingsInput,
SettingsAction,
} from "@/components/settings";
+1
View File
@@ -73,6 +73,7 @@ class PluginRegistry {
queryClient: new QueryClient(),
cleanup: () => undefined,
surfaces: [],
settingsScreens: [],
sidebarItems: [],
workspacePanels: [],
commandCenterItems: [],
+128
View File
@@ -0,0 +1,128 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import type { PluginHostProps } from "@getpaseo/plugin";
import { SettingsAction } from "@/components/settings";
import { Button } from "@/components/ui/button";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import { useInstalledPlugin } from "../registry";
import { PluginRuntimeBoundary } from "../runtime-boundary";
import { SurfaceErrorBoundary } from "../surface-error-boundary";
import { createPluginSurfaceRuntime } from "../surface-runtime";
import { toPluginTheme } from "../theme";
import { buildPluginSettingsRoute } from "./routes";
interface SettingsIdentity {
serverId: string;
pluginId: string;
screenId: string;
}
function SettingsLink({
serverId,
pluginId,
screenId,
title,
}: SettingsIdentity & { title: string }) {
const { t } = useTranslation();
const open = useCallback(
() => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)),
[serverId, pluginId, screenId],
);
return (
<SettingsAction label={title} actionLabel={t("settings.plugins.screens.open")} onPress={open} />
);
}
export function PluginSettingsLinks({ serverId, pluginId }: Omit<SettingsIdentity, "screenId">) {
const plugin = useInstalledPlugin(serverId, pluginId);
const supported = useHostFeature(serverId, "pluginSettings");
if (!supported || !plugin) return null;
return (
<>
{plugin.settingsScreens.map((screen) => (
<SettingsLink
key={screen.id}
serverId={serverId}
pluginId={pluginId}
screenId={screen.id}
title={screen.title}
/>
))}
</>
);
}
function SettingsContent({
serverId,
pluginId,
screenId,
theme,
}: SettingsIdentity & { theme: PluginHostProps["theme"] }) {
const { t } = useTranslation();
const plugin = useInstalledPlugin(serverId, pluginId);
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const supported = useHostFeature(serverId, "pluginSettings");
const compact = useIsCompactFormFactor();
const hosts = useHosts();
const [attempt, setAttempt] = useState(0);
const screen = plugin?.settingsScreens.find((item) => item.id === screenId);
const retry = useCallback(() => setAttempt((value) => value + 1), []);
const renderError = useCallback(
(error: string) => (
<View>
<Text style={styles.message}>{error}</Text>
<Button variant="outline" size="sm" onPress={retry}>
{t("common.actions.retry")}
</Button>
</View>
),
[retry, t],
);
const host = useMemo(
() => ({
id: serverId,
label: hosts.find((candidate) => candidate.serverId === serverId)?.label ?? serverId,
}),
[hosts, serverId],
);
const platform = Platform.OS === "ios" || Platform.OS === "android" ? Platform.OS : "web";
const layout = useMemo<PluginHostProps["layout"]>(
() => ({ compact, platform }),
[compact, platform],
);
if (!connected)
return <Text style={styles.message}>{t("settings.plugins.screens.offline")}</Text>;
// COMPAT(pluginSettings): added in v0.8, remove after 2027-03-05.
if (!supported) return <Text style={styles.message}>{t("settings.plugins.screens.update")}</Text>;
const runtime = createPluginSurfaceRuntime(client, pluginId);
if (!plugin || !screen || !runtime)
return <Text style={styles.message}>{t("settings.plugins.screens.unavailable")}</Text>;
const Component = screen.Component;
return (
<View>
<SurfaceErrorBoundary
installation={plugin}
Surface={Component}
resetKey={attempt}
renderError={renderError}
>
<PluginRuntimeBoundary plugin={plugin} runtime={runtime}>
<Component theme={theme} layout={layout} host={host} />
</PluginRuntimeBoundary>
</SurfaceErrorBoundary>
</View>
);
}
const ThemedSettingsContent = withUnistyles(SettingsContent);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginSettingsContent(props: SettingsIdentity) {
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
}
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
@@ -0,0 +1,6 @@
export function buildPluginSettingsRoute(serverId: string, pluginId: string, screenId: string) {
return {
pathname: "/settings/hosts/[serverId]/plugins/[pluginId]/[screenId]" as const,
params: { serverId, pluginId, screenId },
};
}
@@ -0,0 +1,83 @@
import { useMemo } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRpc, type SettingsDefinition, type SettingsState } from "@getpaseo/plugin";
import { settingsRpc } from "@getpaseo/plugin/host";
import { useReplicaQuery } from "@/data/query";
import { z, type ZodType } from "zod";
export const pluginSettingsKey = (id: string) => ["plugin-settings", id] as const;
function message(error: unknown): string {
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
return error instanceof Error ? error.message : String(error);
}
export function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsState<Schema> {
const rpc = useMemo(() => settingsRpc(definition.id), [definition.id]);
const read = useRpc(rpc.read);
const write = useRpc(rpc.write);
const reset = useRpc(rpc.reset);
const client = useQueryClient();
const key = pluginSettingsKey(definition.id);
const query = useReplicaQuery({
queryKey: key,
pushEvent: "plugin_settings_changed",
queryFn: () => read({}),
retry: false,
});
const mutation = useMutation({
mutationFn: async (
input: { revision: string; values: z.output<Schema> } | { revision: string },
) => {
const result =
"values" in input
? await write({
revision: input.revision,
values: z.json().parse(await definition.schema.parseAsync(input.values)),
})
: await reset(input);
if (result.status !== "saved") throw new Error(result.error);
client.setQueryData(key, { ...result, status: "ready" });
},
});
async function save(values: z.output<Schema>, revision: string) {
try {
await mutation.mutateAsync({ values, revision });
return true;
} catch {
return false;
}
}
async function resetValues() {
if (!query.data) return false;
try {
await mutation.mutateAsync({ revision: query.data.revision });
return true;
} catch {
return false;
}
}
const actions = {
saving: mutation.isPending,
saveError: mutation.error ? message(mutation.error) : null,
save,
reset: resetValues,
reload: async () => {
mutation.reset();
await query.refetch();
},
};
if (query.isPending) return { ...actions, status: "loading" };
if (query.isError) return { ...actions, status: "error", error: message(query.error) };
if (query.data.status === "invalid") return { ...actions, ...query.data };
const parsed = definition.schema.safeParse(query.data.values);
if (!parsed.success)
return {
...actions,
status: "invalid",
revision: query.data.revision,
error: parsed.error.message,
};
return { ...actions, ...query.data, values: parsed.data };
}
@@ -10,6 +10,7 @@ function installed(serverId: string, contributionId = "main"): InstalledPlugin {
serverId,
clientBundle: serverId,
queryClient: new QueryClient(),
settingsScreens: [],
surfaces: [{ id: "surface", Component: () => null }],
sidebarItems: [
{
@@ -18,6 +18,7 @@ function installation(
clientBundle: serverId,
queryClient: new QueryClient(),
cleanup: () => undefined,
settingsScreens: [],
surfaces: surfaces.map((id) => ({ id, Component: () => null })),
sidebarItems: sidebarItems.map((item) => ({
...item,
@@ -7,6 +7,7 @@ interface SurfaceErrorBoundaryProps {
installation: object;
resetKey?: unknown;
Surface: unknown;
renderError?(error: string): ReactNode;
}
interface SurfaceErrorBoundaryState {
@@ -40,6 +41,7 @@ export class SurfaceErrorBoundary extends Component<
render() {
if (this.state.error) {
if (this.props.renderError) return this.props.renderError(this.state.error);
return <Text style={styles.errorText}>Plugin failed: {this.state.error}</Text>;
}
return this.props.children;
+1
View File
@@ -50,6 +50,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install
serverId,
clientBundle: serverId,
queryClient: new QueryClient(),
settingsScreens: [],
surfaces: [],
sidebarItems: [],
workspacePanels: [],
@@ -13,6 +13,7 @@ function plugin(input: {
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarItems: [],
workspacePanels: [],
@@ -30,16 +30,6 @@ vi.mock("../icons", () => ({
Icon: () => null,
resolvePluginIcon: () => () => null,
}));
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: (theme: object) => object) =>
factory({
colors: { foregroundMuted: "gray", statusDanger: "red" },
spacing: [0, 4, 8, 12, 16],
}),
},
withUnistyles: (Component: React.ComponentType) => Component,
}));
import { pluginRegistry } from "../registry";
import { PluginTimelineItemView } from "./view";
+3
View File
@@ -7,6 +7,7 @@ import type {
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSettingsScreenContribution,
PluginThemeContribution,
PluginTimelineRendererContribution,
PluginTimelineTransformerContribution,
@@ -22,6 +23,7 @@ export interface EvaluatedPlugin {
id: string;
cleanup: PluginCleanup;
surfaces: PluginSurfaceContribution[];
settingsScreens: PluginSettingsScreenContribution[];
sidebarItems: PluginSidebarContribution[];
workspacePanels: EvaluatedPluginWorkspacePanelContribution[];
commandCenterItems: PluginCommandCenterItemContribution[];
@@ -45,6 +47,7 @@ export type {
PluginComposerPillContribution,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSettingsScreenContribution,
PluginThemeContribution,
PluginTimelineRendererContribution,
PluginTimelineTransformerContribution,
@@ -10,6 +10,7 @@ function installed(): InstalledPlugin {
clientBundle: "bundle",
queryClient: new QueryClient(),
cleanup: () => {},
settingsScreens: [],
surfaces: [],
sidebarItems: [],
workspacePanels: [
@@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Alert } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { providerUsageCopy } from "./copy";
import { ProviderUsageList } from "./list";
import type { ProviderUsageView } from "./types";
@@ -28,8 +28,8 @@ import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-moda
import { ProjectEditSheet } from "@/components/project-edit-sheet";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import { SettingsTextAreaCard } from "@/components/settings-textarea";
import { SettingsGroup } from "@/screens/settings/settings-group";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsGroup } from "@/components/settings/headings/settings-group";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
import { useProjects } from "@/hooks/use-projects";
import type { ProjectEditFormSnapshot } from "@/projects/edit-form";
@@ -7,27 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ProjectHostEntry, ProjectSummary, WorkspaceSummary } from "@/utils/projects";
import type { ProjectHostError, UseProjectsResult } from "@/hooks/use-projects";
const { theme, projectsState, push } = vi.hoisted(() => ({
theme: {
spacing: { 0: 0, 1: 4, "1.5": 6, 2: 8, 3: 12, 4: 16, 6: 24, 8: 32 },
iconSize: { sm: 14, md: 20 },
fontSize: { xs: 11, sm: 13, base: 15 },
fontWeight: { normal: "400" as const, medium: "500" as const },
borderRadius: { sm: 4, md: 6, lg: 8, full: 999 },
opacity: { 50: 0.5 },
colors: {
surface0: "#000",
surface1: "#111",
surface2: "#222",
surface3: "#333",
surfaceSidebarHover: "#1a1a1a",
foreground: "#fff",
foregroundMuted: "#aaa",
border: "#444",
accent: "#0a84ff",
palette: { red: { 300: "#ff6b6b" } },
},
},
const { projectsState, push } = vi.hoisted(() => ({
projectsState: {
current: {
projects: [],
@@ -40,7 +20,8 @@ const { theme, projectsState, push } = vi.hoisted(() => ({
push: vi.fn(),
}));
vi.mock("react-native", () => {
vi.mock("react-native", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-native")>();
const passthrough = ({
children,
testID,
@@ -87,6 +68,7 @@ vi.mock("react-native", () => {
};
return {
...actual,
View: ({ children, testID }: { children?: React.ReactNode; testID?: string }) =>
React.createElement("div", { "data-testid": testID }, children),
Text: ({ children }: { children?: React.ReactNode }) =>
@@ -101,21 +83,15 @@ vi.mock("react-native", () => {
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) =>
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
},
useUnistyles: () => ({ theme }),
}));
vi.mock("lucide-react-native", () => {
vi.mock("lucide-react-native", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react-native")>();
const icon = (name: string) => {
const Icon = () => React.createElement("span", { "data-icon": name });
Icon.displayName = name;
return Icon;
};
return {
...actual,
ChevronRight: icon("ChevronRight"),
MoreVertical: icon("MoreVertical"),
ExternalLink: icon("ExternalLink"),
+28 -3
View File
@@ -48,7 +48,7 @@ import { HostPicker as SharedHostPicker } from "@/components/hosts/host-picker";
import { HostStatusDot } from "@/components/host-status-dot";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { AppearanceSection } from "@/screens/settings/appearance/appearance-section";
import { LayoutSection } from "@/screens/settings/layout/layout-section";
import {
@@ -112,6 +112,9 @@ import {
HostWorkspacesPage,
HostTerminalsPage,
} from "@/screens/settings/host-page";
import { resolvePluginIcon } from "@/plugins/icons";
import { PluginSettingsContent } from "@/plugins/settings";
import { useInstalledPlugins } from "@/plugins/registry";
import { HostPluginsPage } from "@/screens/settings/plugins-page";
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
import ProjectsScreen from "@/screens/projects-screen";
@@ -1066,6 +1069,7 @@ function SettingsSidebar({
let selectedHostSection: HostSectionSlug | null = null;
if (view.kind === "host") selectedHostSection = view.section;
if (view.kind === "project") selectedHostSection = "projects";
if (view.kind === "plugin") selectedHostSection = "plugins";
const sidebarBody = (
<>
@@ -1204,7 +1208,9 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
const sortedHosts = useSortedHosts(hosts, localServerId);
const lastWorkspaceSelection = useLastWorkspaceSelection();
const routedSettingsHostServerId =
view.kind === "host" || view.kind === "project" ? view.serverId : null;
view.kind === "host" || view.kind === "project" || view.kind === "plugin"
? view.serverId
: null;
const [selectedSettingsHostServerId, setSelectedSettingsHostServerId] = useState<string | null>(
routedSettingsHostServerId ?? lastWorkspaceSelection?.serverId ?? null,
);
@@ -1219,7 +1225,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
// The host the four sections scope to: the host on the active view, otherwise
// the picker choice, otherwise the connected local daemon, otherwise the first host.
const activeHostServerId = useMemo(() => {
if (view.kind === "host" || view.kind === "project") return view.serverId;
if (view.kind === "host" || view.kind === "project" || view.kind === "plugin")
return view.serverId;
return resolveActiveHostServerId({
selectedServerId: selectedSettingsHostServerId,
localServerId,
@@ -1425,11 +1432,21 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
returnFromSettings({ kind: "root" });
}, []);
const installedPlugins = useInstalledPlugins();
const detailHeader = ((): {
title: string;
Icon: ComponentType<{ size: number; color: string }>;
titleAccessory?: ReactNode;
} | null => {
if (view.kind === "plugin") {
const screen = installedPlugins
.find((plugin) => plugin.serverId === view.serverId && plugin.id === view.pluginId)
?.settingsScreens.find((candidate) => candidate.id === view.screenId);
return {
title: `${view.pluginId} · ${screen?.title ?? t("settings.title")}`,
Icon: screen ? resolvePluginIcon(screen.icon) : Blocks,
};
}
if (view.kind === "host") {
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
if (!item) return null;
@@ -1451,6 +1468,14 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
content = isDesktopApp ? <LayoutSection /> : null;
} else {
content = (() => {
if (view.kind === "plugin")
return (
<PluginSettingsContent
serverId={view.serverId}
pluginId={view.pluginId}
screenId={view.screenId}
/>
);
if (view.kind === "host") {
return renderHostSettingsContent(view, handleHostRemoved);
}
@@ -16,8 +16,8 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Switch } from "@/components/ui/switch";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsCard, SettingsSwitch } from "@/components/settings";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useContributedThemes } from "@/appearance/provider";
import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import {
@@ -234,17 +234,12 @@ interface AutoExpandReasoningRowProps {
function AutoExpandReasoningRow({ value, onChange }: AutoExpandReasoningRowProps) {
const { t } = useTranslation();
return (
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.general.autoExpandReasoning.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.general.autoExpandReasoning.description")}
</Text>
</View>
<Switch value={value} onValueChange={onChange} />
</View>
<SettingsSwitch
label={t("settings.general.autoExpandReasoning.label")}
hint={t("settings.general.autoExpandReasoning.description")}
value={value}
onValueChange={onChange}
/>
);
}
@@ -256,19 +251,12 @@ interface ChatOutlineRowProps {
function ChatOutlineRow({ value, onChange }: ChatOutlineRowProps) {
const { t } = useTranslation();
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.appearance.chatOutline.title")}</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.appearance.chatOutline.description")}
</Text>
</View>
<Switch
value={value}
onValueChange={onChange}
accessibilityLabel={t("settings.appearance.chatOutline.title")}
/>
</View>
<SettingsSwitch
label={t("settings.appearance.chatOutline.title")}
hint={t("settings.appearance.chatOutline.description")}
value={value}
onValueChange={onChange}
/>
);
}
@@ -309,7 +297,7 @@ function ToolCallDetailRow({ value, onChange }: ToolCallDetailRowProps) {
const { t } = useTranslation();
const selectedLabel = getToolCallDetailLevelLabel(t, value);
return (
<View style={[settingsStyles.row, settingsStyles.rowBorder]}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.general.toolCallDetail.label")}</Text>
<Text style={settingsStyles.rowHint}>
@@ -693,7 +681,7 @@ export function AppearanceSection() {
</View>
</SettingsSection>
<SettingsSection title={t("settings.appearance.detailLevel.title")}>
<View style={settingsStyles.card}>
<SettingsCard>
<AutoExpandReasoningRow
value={settings.autoExpandReasoning}
onChange={handleAutoExpandReasoningChange}
@@ -708,7 +696,7 @@ export function AppearanceSection() {
onChange={handleChatOutlineChange}
/>
) : null}
</View>
</SettingsCard>
</SettingsSection>
<SidebarNavSection />
<SettingsSection title={t("settings.appearance.fonts.title")}>
@@ -17,7 +17,7 @@ import { Shortcut } from "@/components/ui/shortcut";
import { Switch } from "@/components/ui/switch";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import { resolvePluginIcon } from "@/plugins/icons";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import {
builtinSidebarNavLabelKey,
builtinSidebarNavShortcutAction,
@@ -4,7 +4,7 @@ import { Text, View } from "react-native";
import { Switch } from "@/components/ui/switch";
import { useAppSettings } from "@/hooks/use-settings";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "./settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
export function EditorSection() {
const { t } = useTranslation();
@@ -13,7 +13,7 @@ import {
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { WorkspaceMetaRow } from "@/components/sidebar/workspace-meta-row";
import { useToast } from "@/contexts/toast-context";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import {
HOST_BADGE_DISPLAYS,
HOST_COLORS,
@@ -59,7 +59,7 @@ import { ProvidersSection } from "@/screens/settings/providers-section";
import { ProviderUsageSettingsSection } from "@/provider-usage/settings-section";
import { useProviderUsage } from "@/provider-usage/use-provider-usage";
import { HostAppearanceSection } from "@/screens/settings/host-appearance-section";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useSessionStore } from "@/stores/session-store";
import { settingsStyles } from "@/styles/settings";
import type { HostConnection, HostProfile } from "@/types/host-connection";
@@ -6,7 +6,7 @@ import { MoreHorizontal, Pencil, Undo2, X } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { settingsStyles } from "@/styles/settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -1,16 +1,11 @@
import { Fragment, useCallback } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { DropdownTrigger } from "@/components/ui/dropdown-trigger";
import { SettingsSection, SettingsCard, SettingsSelect } from "@/components/settings";
import {
useAppSettings,
type OpenInSidePanePreferences,
type PullRequestOpenLocation,
} from "@/hooks/use-settings";
import { SettingsSection } from "@/screens/settings/settings-section";
import { settingsStyles } from "@/styles/settings";
const SOURCES = [
"explorerFiles",
@@ -22,72 +17,38 @@ const SOURCES = [
type LayoutPreferenceSource = keyof OpenInSidePanePreferences | "pullRequests";
function destinationTriggerStyle({
pressed,
open,
}: PressableStateCallbackType & { open?: boolean }) {
return [styles.destinationTrigger, (pressed || open) && styles.destinationTriggerActive];
}
function LayoutPreferenceRow({
source,
destination,
first,
allowExplorer,
onDestinationChange,
}: {
source: LayoutPreferenceSource;
destination: PullRequestOpenLocation;
first: boolean;
allowExplorer?: boolean;
onDestinationChange: (
source: LayoutPreferenceSource,
destination: PullRequestOpenLocation,
) => void;
onDestinationChange(source: LayoutPreferenceSource, destination: PullRequestOpenLocation): void;
}) {
const { t } = useTranslation();
const destinationLabel = t(`settings.layout.openInSidePane.destinations.${destination}`);
const selectMain = useCallback(
() => onDestinationChange(source, "main"),
[onDestinationChange, source],
const options = useMemo(() => {
const destinations = allowExplorer
? (["main", "side", "explorer"] as const)
: (["main", "side"] as const);
return destinations.map((value) => ({
value,
label: t(`settings.layout.openInSidePane.destinations.${value}`),
}));
}, [allowExplorer, t]);
const change = useCallback(
(value: PullRequestOpenLocation) => onDestinationChange(source, value),
[source, onDestinationChange],
);
const selectSide = useCallback(
() => onDestinationChange(source, "side"),
[onDestinationChange, source],
);
const selectExplorer = useCallback(
() => onDestinationChange(source, "explorer"),
[onDestinationChange, source],
);
const label = t(`settings.layout.openInSidePane.sources.${source}.label`);
return (
<View style={[settingsStyles.row, first ? null : settingsStyles.rowBorder]}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{label}</Text>
</View>
<DropdownMenu>
<DropdownTrigger
style={destinationTriggerStyle}
accessibilityRole="button"
accessibilityLabel={`${label}: ${destinationLabel}`}
>
<Text style={styles.destinationLabel}>{destinationLabel}</Text>
</DropdownTrigger>
<DropdownMenuContent side="bottom" align="end" width={180}>
<DropdownMenuItem selected={destination === "main"} onSelect={selectMain}>
{t("settings.layout.openInSidePane.destinations.main")}
</DropdownMenuItem>
<DropdownMenuItem selected={destination === "side"} onSelect={selectSide}>
{t("settings.layout.openInSidePane.destinations.side")}
</DropdownMenuItem>
{allowExplorer ? (
<DropdownMenuItem selected={destination === "explorer"} onSelect={selectExplorer}>
{t("settings.layout.openInSidePane.destinations.explorer")}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</View>
<SettingsSelect
label={t(`settings.layout.openInSidePane.sources.${source}.label`)}
value={destination}
options={options}
onValueChange={change}
/>
);
}
@@ -108,42 +69,22 @@ export function LayoutSection() {
);
return (
<SettingsSection title={t("settings.layout.openInSidePane.title")}>
<View style={settingsStyles.card}>
{SOURCES.map((source, index) => (
<Fragment key={source}>
<LayoutPreferenceRow
source={source}
destination={settings.openInSidePane[source] ? "side" : "main"}
first={index === 0}
onDestinationChange={handleDestinationChange}
/>
</Fragment>
<SettingsCard>
{SOURCES.map((source) => (
<LayoutPreferenceRow
key={source}
source={source}
destination={settings.openInSidePane[source] ? "side" : "main"}
onDestinationChange={handleDestinationChange}
/>
))}
<LayoutPreferenceRow
source="pullRequests"
destination={settings.pullRequestOpenLocation}
first={false}
allowExplorer
onDestinationChange={handleDestinationChange}
/>
</View>
</SettingsCard>
</SettingsSection>
);
}
const styles = StyleSheet.create((theme) => ({
destinationTrigger: {
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
},
destinationTriggerActive: {
backgroundColor: theme.colors.interactionHighlight,
},
destinationLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
}));
@@ -10,7 +10,7 @@ import { SegmentedControl } from "@/components/ui/segmented-control";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { buildSelectableProviderSelectorProviders } from "@/provider-selection/provider-selection";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { settingsStyles } from "@/styles/settings";
const METADATA_GENERATION_DOCS_URL = "https://paseo.sh/docs/metadata-generation";
@@ -58,6 +58,11 @@ vi.mock("@/components/adaptive-modal-sheet", async () => {
vi.mock("react-native-reanimated", () => ({
default: { View: "div" },
Keyframe: class {
duration() {
return this;
}
},
Easing: { ease: "ease", inOut: (value: unknown) => value },
interpolateColor: (value: number, _input: number[], output: string[]) =>
value >= 1 ? output[1] : output[0],
@@ -1,3 +1,4 @@
import { PluginSettingsLinks } from "@/plugins/settings";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
@@ -14,7 +15,7 @@ import { useFetchQuery } from "@/data/query";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { useHostFeature } from "@/runtime/host-features";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { resolvePluginPageState } from "@/screens/settings/plugins-page-state";
import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry";
import { settingsStyles } from "@/styles/settings";
@@ -36,6 +37,7 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine
function PluginRow({
plugin,
serverId,
clientError,
pending,
pendingAction,
@@ -44,6 +46,7 @@ function PluginRow({
supportsLogs,
}: {
plugin: PluginListItem;
serverId: string;
clientError?: string;
pending: boolean;
pendingAction?: PluginRowAction;
@@ -70,37 +73,45 @@ function PluginRow({
if (pendingAction === "enable") toggleLabel = t("settings.plugins.actions.enabling");
else if (pendingAction === "disable") toggleLabel = t("settings.plugins.actions.disabling");
return (
<View style={styles.pluginRow} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
<View style={settingsStyles.rowContent}>
<View style={styles.pluginTitle}>
<Text style={settingsStyles.rowTitle}>{plugin.id}</Text>
<StatusBadge label={statusLabel} variant={badgeVariant} />
<View>
<View style={styles.pluginRow} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
<View style={settingsStyles.rowContent}>
<View style={styles.pluginTitle}>
<Text style={settingsStyles.rowTitle}>{plugin.id}</Text>
<StatusBadge label={statusLabel} variant={badgeVariant} />
</View>
<Text style={settingsStyles.rowHint}>{plugin.path}</Text>
{clientError || plugin.error ? (
<Text style={styles.error}>{clientError ?? plugin.error}</Text>
) : null}
</View>
<Text style={settingsStyles.rowHint}>{plugin.path}</Text>
{clientError || plugin.error ? (
<Text style={styles.error}>{clientError ?? plugin.error}</Text>
) : null}
</View>
<View style={styles.actions}>
{supportsLogs ? (
<Button variant="outline" size="sm" onPress={openLogs} disabled={pending}>
{t("settings.plugins.logs.action")}
<View style={styles.actions}>
{supportsLogs ? (
<Button variant="outline" size="sm" onPress={openLogs} disabled={pending}>
{t("settings.plugins.logs.action")}
</Button>
) : null}
<Button
variant="outline"
size="sm"
onPress={reload}
disabled={pending || !plugin.enabled}
>
{pendingAction === "reload"
? t("settings.plugins.actions.reloading")
: t("settings.plugins.actions.reload")}
</Button>
) : null}
<Button variant="outline" size="sm" onPress={reload} disabled={pending || !plugin.enabled}>
{pendingAction === "reload"
? t("settings.plugins.actions.reloading")
: t("settings.plugins.actions.reload")}
</Button>
<Button variant="outline" size="sm" onPress={toggle} disabled={pending}>
{toggleLabel}
</Button>
<Button variant="outline" size="sm" onPress={remove} disabled={pending}>
{pendingAction === "remove"
? t("settings.plugins.actions.removing")
: t("settings.plugins.actions.remove")}
</Button>
<Button variant="outline" size="sm" onPress={toggle} disabled={pending}>
{toggleLabel}
</Button>
<Button variant="outline" size="sm" onPress={remove} disabled={pending}>
{pendingAction === "remove"
? t("settings.plugins.actions.removing")
: t("settings.plugins.actions.remove")}
</Button>
</View>
</View>
<PluginSettingsLinks serverId={serverId} pluginId={plugin.id} />
</View>
);
}
@@ -347,6 +358,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
return (
<PluginRow
key={plugin.id}
serverId={serverId}
plugin={plugin}
clientError={clientError}
pending={mutation.isPending}
@@ -166,7 +166,7 @@ vi.mock("@/components/ui/loading-spinner", () => ({
LoadingSpinner: () => React.createElement("span", { "data-testid": "loading-spinner" }),
}));
vi.mock("@/screens/settings/settings-info-tip", () => ({
vi.mock("@/components/settings/headings/settings-info-tip", () => ({
SettingsInfoTip: () => null,
}));
@@ -31,7 +31,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SettingsSection } from "@/screens/settings/settings-section";
import { SettingsSection } from "@/components/settings/headings/settings-section";
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
import { confirmDialog } from "@/utils/confirm-dialog";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
+17
View File
@@ -6,6 +6,23 @@ const globalWithTestShims = globalThis as typeof globalThis & Record<string, unk
globalWithTestShims.__DEV__ = false;
// JSDOM has no CSS media-query engine. Give native-web libraries the browser API
// with no active preferences; responsive behavior is exercised in Chromium.
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
window.matchMedia = (media: string) =>
Object.assign(new window.EventTarget(), {
media,
matches: false,
onchange: null,
addListener(listener: EventListener) {
this.addEventListener("change", listener);
},
removeListener(listener: EventListener) {
this.removeEventListener("change", listener);
},
});
}
if (typeof globalThis.self === "undefined") {
globalWithTestShims.self = globalThis;
}
+5
View File
@@ -37,6 +37,11 @@
"types": "./dist/host.d.ts",
"source": "./src/host.ts",
"default": "./dist/host.js"
},
"./ui": {
"types": "./dist/ui.d.ts",
"source": "./src/ui.ts",
"default": "./dist/ui.js"
}
},
"publishConfig": {
+12
View File
@@ -128,6 +128,7 @@ export interface PluginClientOpenPanelOptions extends PluginOpenPanelOptions {
}
export interface PluginClientContext extends PluginCommandCapabilities {
addSettingsScreen(contribution: PluginSettingsScreenContribution): PluginCleanup;
addSurface(id: string, Component: ComponentType<PluginSurfaceProps>): PluginCleanup;
addSidebarItem(contribution: PluginSidebarContribution): PluginCleanup;
addWorkspacePanel(contribution: PluginWorkspacePanelContribution): PluginCleanup;
@@ -157,6 +158,13 @@ export type PluginWorkspacePanelContribution =
Component: ComponentType<PluginAgentPanelProps>;
});
export interface PluginSettingsScreenContribution {
id: string;
title: string;
icon: string;
Component: ComponentType<PluginSurfaceProps>;
}
export interface PluginSurfaceContribution {
id: string;
Component: ComponentType<PluginSurfaceProps>;
@@ -250,6 +258,7 @@ export interface PluginCommandCapabilities {
input: ZodInput<InputSchema>,
): Promise<ZodOutput<OutputSchema>>;
openSurface(id: string): void;
openSettings(id: string): void;
}
export interface PluginGlobalCommandContext extends PluginCommandCapabilities {
@@ -311,6 +320,9 @@ export interface PluginHandlerContext {
}
export interface PluginServerContext {
registerSettings<Schema extends ZodType>(
definition: import("./settings.js").SettingsDefinition<Schema>,
): void;
handle<InputSchema extends ZodType, OutputSchema extends ZodType>(
contract: PluginRpcContract<InputSchema, OutputSchema>,
handler: (
+1
View File
@@ -1,3 +1,4 @@
export { settingsRpc } from "./settings.js";
import { PluginAttachmentSearchPayloadSchema } from "./attachments.js";
export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js";
export {
+8
View File
@@ -1,3 +1,10 @@
import type { SettingsDefinition, SettingsState } from "./settings.js";
import type { ZodType } from "zod";
export { defineSettings, type SettingsDefinition, type SettingsState } from "./settings.js";
export declare function useSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsState<Schema>;
import type { ComponentType } from "react";
import type { PluginAttachmentSourceContribution, PluginIconProps } from "./contracts.js";
@@ -41,6 +48,7 @@ export type {
PluginTheme,
PluginSidebarContribution,
PluginSurfaceContribution,
PluginSettingsScreenContribution,
PluginSurfaceProps,
PluginThemeColors,
PluginThemeContribution,
+65
View File
@@ -0,0 +1,65 @@
import { z } from "zod";
import type { output, ZodType } from "zod";
import { defineRpc } from "./rpc.js";
export interface SettingsDefinition<Schema extends ZodType = ZodType> {
id: string;
scope: "host";
version: number;
schema: Schema;
/** Convert a previous stored version to the current schema. Runs on the host. */
migrate?: (values: unknown, fromVersion: number) => unknown | Promise<unknown>;
}
export function defineSettings<Schema extends ZodType>(
definition: SettingsDefinition<Schema>,
): SettingsDefinition<Schema> {
if (!/^[a-z][a-z0-9_-]*$/.test(definition.id)) throw new Error("Invalid settings ID");
if (definition.scope !== "host") throw new Error("Only host-scoped settings are supported");
if (!Number.isSafeInteger(definition.version) || definition.version < 1)
throw new Error("Invalid settings version");
return definition;
}
export const SettingsReadResultSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("ready"), revision: z.string(), values: z.json() }),
z.object({ status: z.literal("invalid"), revision: z.string(), error: z.string() }),
]);
export const SettingsWriteResultSchema = z.discriminatedUnion("status", [
z.object({ status: z.literal("saved"), revision: z.string(), values: z.json() }),
z.object({ status: z.literal("conflict"), error: z.string() }),
z.object({ status: z.literal("invalid"), error: z.string() }),
]);
export function settingsRpc(id: string) {
return {
read: defineRpc({
name: `settings.${id}.read`,
input: z.object({}),
output: SettingsReadResultSchema,
}),
write: defineRpc({
name: `settings.${id}.write`,
input: z.object({ revision: z.string(), values: z.json() }),
output: SettingsWriteResultSchema,
}),
reset: defineRpc({
name: `settings.${id}.reset`,
input: z.object({ revision: z.string() }),
output: SettingsWriteResultSchema,
}),
};
}
export type SettingsState<Schema extends ZodType> = (
| { status: "loading" }
| { status: "error"; error: string }
| { status: "invalid"; error: string; revision: string }
| { status: "ready"; values: output<Schema>; revision: string }
) & {
saving: boolean;
saveError: string | null;
/** Save an entire document against the revision currently displayed. Never throws. */
save(values: output<Schema>, revision: string): Promise<boolean>;
reset(): Promise<boolean>;
reload(): Promise<void>;
};
+56
View File
@@ -0,0 +1,56 @@
import type { ComponentType, ReactNode, Ref } from "react";
export interface SettingsSectionProps {
title: string;
info?: ReactNode;
trailing?: ReactNode;
children: ReactNode;
testID?: string;
}
export interface SettingsRowProps {
label: string;
hint?: string;
error?: string | null;
children?: ReactNode;
testID?: string;
}
export interface SettingsSwitchProps extends SettingsRowProps {
value: boolean;
onValueChange(value: boolean): void;
disabled?: boolean;
}
export interface SettingsSelectProps<Value extends string = string> extends SettingsRowProps {
value: Value;
options: readonly { label: string; value: Value }[];
onValueChange(value: Value): void;
disabled?: boolean;
}
export interface SettingsInputHandle {
focus(): void;
blur(): void;
getText(): string;
replaceText(text: string): void;
}
export interface SettingsInputProps extends SettingsRowProps {
initialValue?: string;
onChangeText(text: string): void;
placeholder?: string;
disabled?: boolean;
secureTextEntry?: boolean;
ref?: Ref<SettingsInputHandle>;
}
export interface SettingsActionProps extends SettingsRowProps {
actionLabel: string;
onPress(): void;
disabled?: boolean;
}
export declare const SettingsGroup: ComponentType<SettingsSectionProps>;
export declare const SettingsSection: ComponentType<SettingsSectionProps>;
export declare const SettingsCard: ComponentType<{ children: ReactNode; testID?: string }>;
export declare const SettingsRow: ComponentType<SettingsRowProps>;
export declare const SettingsSwitch: ComponentType<SettingsSwitchProps>;
export declare function SettingsSelect<Value extends string>(
props: SettingsSelectProps<Value>,
): ReactNode;
export declare const SettingsInput: ComponentType<SettingsInputProps>;
export declare const SettingsAction: ComponentType<SettingsActionProps>;
+8
View File
@@ -3456,6 +3456,7 @@ export const ServerInfoStatusPayloadSchema = z
// A daemon that predates this flag keeps `addTheme` in the server bundle it compiles,
// so a theme plugin cannot start there at all.
pluginThemes: z.boolean().optional(),
pluginSettings: z.boolean().optional(),
pluginTimelineItems: z.boolean().optional(),
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: z.boolean().optional(),
@@ -3655,6 +3656,12 @@ export const PluginCatalogChangedStatusPayloadSchema = z.object({
pluginId: PluginIdSchema,
});
export const PluginSettingsChangedStatusPayloadSchema = z.object({
status: z.literal("plugin_settings_changed"),
pluginId: PluginIdSchema,
settingsId: z.string(),
});
export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [
AgentCreatedStatusPayloadSchema,
AgentCreateFailedStatusPayloadSchema,
@@ -3664,6 +3671,7 @@ export const KnownStatusPayloadSchema = z.discriminatedUnion("status", [
RestartRequestedStatusPayloadSchema,
DaemonConfigChangedStatusPayloadSchema,
PluginCatalogChangedStatusPayloadSchema,
PluginSettingsChangedStatusPayloadSchema,
]);
export type KnownStatusPayload = z.infer<typeof KnownStatusPayloadSchema>;
+1
View File
@@ -607,6 +607,7 @@ export async function createPaseoDaemon(
const browserToolsBroker = new BrowserToolsBroker({});
const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, {
managedSources: new ManagedPluginSources(config.paseoHome),
settingsDirectory: path.join(config.paseoHome, "plugin-settings"),
});
const serverId = getOrCreateServerId(config.paseoHome, { logger });
+22 -3
View File
@@ -1,5 +1,5 @@
import path from "node:path";
import { stat } from "node:fs/promises";
import { stat, rm } from "node:fs/promises";
import type pino from "pino";
import type { ProviderRegistration } from "@getpaseo/plugin/provider";
import {
@@ -38,6 +38,7 @@ interface PluginRuntimePort {
}
interface PluginServiceDependencies {
settingsDirectory?: string;
runtime?: PluginRuntimePort;
managedSources?: ManagedPluginSources;
}
@@ -63,15 +64,23 @@ export class PluginService {
private lifecycle = Promise.resolve();
private globalStartsBlocked = true;
private started = false;
private readonly settingsListeners = new Set<(pluginId: string, settingsId: string) => void>();
constructor(
logger: pino.Logger,
private readonly configStore: DaemonConfigStore,
daemonVersion: string,
dependencies: PluginServiceDependencies = {},
private readonly dependencies: PluginServiceDependencies = {},
) {
this.logger = logger.child({ module: "plugin-service" });
this.runtime = dependencies.runtime ?? new PluginRuntime(logger, daemonVersion);
this.runtime =
dependencies.runtime ??
new PluginRuntime(logger, daemonVersion, {
settingsDirectory: dependencies.settingsDirectory,
onSettingsChanged: (pluginId, settingsId) => {
for (const listener of this.settingsListeners) listener(pluginId, settingsId);
},
});
this.managedSources = dependencies.managedSources ?? null;
this.runtime.subscribe((pluginId, error) => {
this.removeProviderRegistrations(pluginId);
@@ -80,6 +89,11 @@ export class PluginService {
});
}
subscribeSettings(listener: (pluginId: string, settingsId: string) => void): () => void {
this.settingsListeners.add(listener);
return () => this.settingsListeners.delete(listener);
}
subscribe(listener: (pluginId: string) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
@@ -318,6 +332,11 @@ export class PluginService {
this.errors.delete(pluginId);
this.notify(pluginId);
await this.managedSources?.remove(pluginId);
if (this.dependencies.settingsDirectory)
await rm(path.join(this.dependencies.settingsDirectory, pluginId), {
recursive: true,
force: true,
});
});
}
@@ -14,7 +14,13 @@ export interface PluginProviderMetadata {
}
export type PluginProcessRequest =
| { type: "initialize"; pluginId: string; bundle: string; appVersion: string }
| {
type: "initialize";
pluginId: string;
bundle: string;
appVersion: string;
settingsDirectory?: string;
}
| { type: "invoke"; requestId: string; method: string; input: unknown }
| {
type: "provider.connect";
@@ -34,6 +40,7 @@ export type PluginProcessRequest =
| { type: "paseo_close" };
export type PluginProcessMessage =
| { type: "settings.changed"; settingsId: string }
| { type: "ready"; methods: string[]; providers: PluginProviderMetadata[] }
| { type: "result"; requestId: string; output: unknown }
| { type: "error"; requestId: string; error: string }
@@ -85,6 +92,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
pluginId: z.string().min(1),
bundle: z.string(),
appVersion: z.string(),
settingsDirectory: z.string().optional(),
})
.strict(),
z
@@ -121,6 +129,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
export const PluginProcessMessageSchema: z.ZodType<PluginProcessMessage> = z.discriminatedUnion(
"type",
[
z.object({ type: z.literal("settings.changed"), settingsId: z.string() }).strict(),
z
.object({
type: z.literal("ready"),
@@ -4,7 +4,13 @@ import {
type PluginProcessRequest,
} from "./plugin-process-protocol.js";
import { createRequire } from "node:module";
import { defineAttachmentSource, defineRpc, type PluginRpcContract } from "@getpaseo/plugin";
import {
defineSettings,
type SettingsDefinition,
defineAttachmentSource,
defineRpc,
type PluginRpcContract,
} from "@getpaseo/plugin";
import {
ProviderEventSchema,
type ProviderConnection,
@@ -21,6 +27,20 @@ import {
} from "./plugin-sdk-specifiers.js";
import { createPluginClientId } from "./plugin-session-identity.js";
import { PluginSettingsStore } from "./settings/index.js";
let settingsStore: PluginSettingsStore | null = null;
function registerSettings(definition: SettingsDefinition) {
if (!settingsStore) throw new Error("Plugin settings storage is unavailable");
const handlers = settingsStore.register(definition);
register(handlers.read.contract, handlers.read.handle);
register(handlers.write.contract, (input) =>
handlers.write.handle(handlers.write.contract.input.parse(input)),
);
register(handlers.reset.contract, (input) =>
handlers.reset.handle(handlers.reset.contract.input.parse(input)),
);
}
type RpcHandler = (input: unknown, context: PluginHandlerContext) => unknown | Promise<unknown>;
interface RegisteredRpc {
@@ -186,6 +206,7 @@ async function closeProviderConnection(connectionId: string): Promise<void> {
const pluginAuthorRuntime = {
defineAttachmentSource,
defineSettings,
defineRpc,
Icon() {
throw new Error("Icon is available only in plugin client code");
@@ -211,7 +232,7 @@ function evaluateBundle(bundle: string): void {
if (typeof setup !== "function") {
throw new Error("Plugin server bundle must default export a function");
}
const contributedCleanup = setup({ handle: register, registerProvider });
const contributedCleanup = setup({ handle: register, registerProvider, registerSettings });
if (typeof contributedCleanup !== "function") {
throw new Error("Plugin contribution must return a cleanup function");
}
@@ -237,6 +258,11 @@ async function initialize(message: Extract<PluginProcessRequest, { type: "initia
});
paseo = createPaseoApi(daemonClient);
await daemonClient.connect();
settingsStore = message.settingsDirectory
? new PluginSettingsStore(message.settingsDirectory, (settingsId) =>
send({ type: "settings.changed", settingsId }),
)
: null;
evaluateBundle(message.bundle);
send({
type: "ready",
@@ -6,6 +6,7 @@
// published — that scope is not ours. Plugins scaffolded against that name still import it, so
// both spellings resolve. Remove the @paseo/* entries after 2026-11-19.
export const PLUGIN_CLIENT_ONLY_SDK_SPECIFIERS = [
"@getpaseo/plugin/ui",
"@getpaseo/plugin/react-native",
"@paseo/plugin/react-native",
] as const;
+10 -1
View File
@@ -89,6 +89,8 @@ interface RemoteProviderConnection {
}
interface PluginRuntimeDependencies {
settingsDirectory?: string;
onSettingsChanged?: (pluginId: string, settingsId: string) => void;
spawnChild?: () => PluginChild;
sessionHost?: PluginPaseoSessionHost;
}
@@ -270,7 +272,7 @@ export class PluginRuntime {
constructor(
logger: pino.Logger,
private readonly daemonVersion: string,
dependencies: PluginRuntimeDependencies = {},
private readonly dependencies: PluginRuntimeDependencies = {},
) {
this.logger = logger.child({ module: "plugins" });
this.spawnChild = dependencies.spawnChild ?? spawnPluginChild;
@@ -532,6 +534,9 @@ export class PluginRuntime {
pluginId,
appVersion: this.daemonVersion,
bundle: serverBundle,
settingsDirectory: this.dependencies.settingsDirectory
? path.join(this.dependencies.settingsDirectory, pluginId)
: undefined,
}).catch(fail);
},
);
@@ -562,6 +567,10 @@ export class PluginRuntime {
}
private handleChildMessage(loaded: LoadedPlugin, message: PluginProcessMessage): void {
if (message.type === "settings.changed") {
this.dependencies.onSettingsChanged?.(loaded.id, message.settingsId);
return;
}
if (message.type.startsWith("provider.")) {
this.handleProviderMessage(loaded, message);
return;
@@ -0,0 +1,73 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { z } from "zod";
import { settingsRpc } from "@getpaseo/plugin/host";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
test("two clients share settings, observe changes, and preserve values through plugin lifecycle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "settings-plugin-"));
const daemon = await createTestPaseoDaemon();
const first = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" });
const second = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.7.2" });
const rpc = settingsRpc("display");
const read = async (client: DaemonClient, pluginId = "settings-test") =>
rpc.read.output.parse(await client.invokePluginRpc(pluginId, rpc.read.name, {}));
const changed: string[] = [];
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "settings-test" }),
);
await writeFile(
path.join(directory, "index.server.ts"),
`import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
export default function(server) { server.registerSettings(defineSettings({ id: "display", scope: "host", version: 1, schema: z.object({ enabled: z.boolean().default(true) }) })); return () => {}; }`,
);
await first.connect();
await second.connect();
second.on("status", (message) => {
if (message.payload.status === "plugin_settings_changed")
changed.push(z.string().parse(message.payload.settingsId));
});
await first.patchDaemonConfig({ pluginsEnabled: true });
await first.installDirectoryPlugin(directory);
const initial = await read(first);
expect(initial).toMatchObject({ status: "ready", values: { enabled: true } });
expect(
await second.invokePluginRpc("settings-test", rpc.write.name, {
revision: initial.revision,
values: { enabled: false },
}),
).toMatchObject({ status: "saved" });
await expect.poll(() => changed).toEqual(["display"]);
expect(await read(first)).toMatchObject({ values: { enabled: false } });
expect(
await first.invokePluginRpc("settings-test", rpc.write.name, {
revision: initial.revision,
values: { enabled: true },
}),
).toMatchObject({ status: "conflict" });
await first.reloadPlugin("settings-test");
expect(await read(first)).toMatchObject({ values: { enabled: false } });
await first.disablePlugin("settings-test");
await first.enablePlugin("settings-test");
expect(await read(first)).toMatchObject({ values: { enabled: false } });
await first.installDirectoryPlugin(directory, "other-installation");
expect(await read(first, "other-installation")).toMatchObject({ values: { enabled: true } });
await first.removePlugin("settings-test");
await first.installDirectoryPlugin(directory);
expect(await read(first)).toMatchObject({ values: { enabled: true } });
} catch (error) {
console.error(await first.getPluginLogs("settings-test"));
throw error;
} finally {
await first.close();
await second.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
@@ -0,0 +1,131 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, expect, test } from "vitest";
import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
import { PluginSettingsStore } from "./index.js";
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
const definition = defineSettings({
id: "display",
scope: "host",
version: 1,
schema: z.object({
enabled: z.boolean().default(true),
count: z.number().int().min(1).default(5),
}),
});
async function setup() {
const directory = await mkdtemp(path.join(tmpdir(), "plugin-settings-"));
roots.push(directory);
const changes: string[] = [];
const store = new PluginSettingsStore(directory, (id) => changes.push(id));
return { directory, changes, handlers: store.register(definition) };
}
test("defaults, atomic saves, concurrent revisions, and restart persistence", async () => {
const { directory, changes, handlers } = await setup();
expect(await handlers.read.handle()).toEqual({
status: "ready",
revision: "missing",
values: { enabled: true, count: 5 },
});
const outcomes = await Promise.all([
handlers.write.handle({ revision: "missing", values: { enabled: false, count: 10 } }),
handlers.write.handle({ revision: "missing", values: { enabled: true, count: 20 } }),
]);
expect(outcomes.map((result) => result.status)).toEqual(["saved", "conflict"]);
const reopened = new PluginSettingsStore(directory, () => {}).register(definition);
expect(await reopened.read.handle()).toMatchObject({
status: "ready",
values: { enabled: false, count: 10 },
});
expect(changes).toEqual(["display"]);
});
test("validation failure preserves disk and leaves the writer usable", async () => {
const { handlers, changes } = await setup();
expect(await handlers.write.handle({ revision: "missing", values: { count: -1 } })).toMatchObject(
{ status: "invalid" },
);
expect(await handlers.read.handle()).toMatchObject({ revision: "missing" });
expect(changes).toEqual([]);
expect(await handlers.write.handle({ revision: "missing", values: { count: 2 } })).toMatchObject({
status: "saved",
values: { enabled: true, count: 2 },
});
});
test("migrates once, persists the validated version, and rejects old clients", async () => {
const { directory, handlers } = await setup();
const saved = await handlers.write.handle({ revision: "missing", values: { count: 7 } });
if (saved.status !== "saved") throw new Error("save failed");
let migrations = 0;
const upgraded = new PluginSettingsStore(directory, () => {}).register({
...definition,
version: 2,
schema: z.object({ total: z.number().default(0) }),
migrate(values, version) {
migrations++;
expect(version).toBe(1);
return { total: z.object({ count: z.number() }).parse(values).count };
},
});
expect(await upgraded.read.handle()).toMatchObject({ status: "ready", values: { total: 7 } });
await upgraded.read.handle();
expect(migrations).toBe(1);
expect(
await handlers.write.handle({ revision: saved.revision, values: { count: 9 } }),
).toMatchObject({ status: "conflict" });
const newer = await handlers.read.handle();
expect(newer).toMatchObject({
status: "invalid",
error: expect.stringContaining("newer plugin"),
});
expect(
await handlers.write.handle({ revision: newer.revision, values: { count: 9 } }),
).toMatchObject({ status: "invalid" });
expect(await upgraded.read.handle()).toMatchObject({ values: { total: 7 } });
});
test("failed migrations and corrupt data survive reads until an explicit reset", async () => {
const { directory, handlers } = await setup();
await handlers.write.handle({ revision: "missing", values: { count: 7 } });
const file = path.join(directory, "display.json");
const before = await readFile(file, "utf8");
const upgraded = new PluginSettingsStore(directory, () => {}).register({
...definition,
version: 2,
migrate() {
throw new Error("migration failed");
},
});
expect(await upgraded.read.handle()).toMatchObject({
status: "invalid",
error: "migration failed",
});
expect(await readFile(file, "utf8")).toBe(before);
await writeFile(file, "broken JSON");
const invalid = await handlers.read.handle();
expect(invalid.status).toBe("invalid");
expect(await readFile(file, "utf8")).toBe("broken JSON");
expect(await handlers.reset.handle({ revision: invalid.revision })).toMatchObject({
status: "saved",
values: { enabled: true, count: 5 },
});
});
test("installation namespaces and definition IDs remain separate", async () => {
const first = await setup();
const second = await setup();
await first.handlers.write.handle({ revision: "missing", values: { enabled: false } });
expect(await second.handlers.read.handle()).toMatchObject({ values: { enabled: true } });
const store = new PluginSettingsStore(first.directory, () => {});
store.register(definition);
expect(() => store.register(definition)).toThrow("Duplicate settings");
expect(() => store.register({ ...definition, id: "../escape" })).toThrow("Invalid settings ID");
});
@@ -0,0 +1,133 @@
import { createHash, randomUUID } from "node:crypto";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { defineSettings, type SettingsDefinition } from "@getpaseo/plugin";
import { settingsRpc } from "@getpaseo/plugin/host";
const envelopeSchema = z.object({ version: z.number().int().positive(), values: z.json() });
function message(error: unknown): string {
if (error instanceof z.ZodError) return error.issues.map((issue) => issue.message).join("\n");
return error instanceof Error ? error.message : String(error);
}
const revisionOf = (raw: string) => createHash("sha256").update(raw).digest("hex");
/** One instance per installation. The subprocess lifetime gives writes a single owner. */
export class PluginSettingsStore {
private readonly definitions = new Map<string, SettingsDefinition>();
private queue: Promise<unknown> = Promise.resolve();
private readonly directory: string;
private readonly changed: (id: string) => void;
constructor(directory: string, changed: (id: string) => void) {
this.directory = directory;
this.changed = changed;
}
register(definition: SettingsDefinition) {
defineSettings(definition);
if (this.definitions.has(definition.id))
throw new Error(`Duplicate settings: ${definition.id}`);
this.definitions.set(definition.id, definition);
const rpc = settingsRpc(definition.id);
return {
read: { contract: rpc.read, handle: () => this.serial(() => this.read(definition)) },
write: {
contract: rpc.write,
handle: (input: z.output<typeof rpc.write.input>) =>
this.serial(() => this.write(definition, input.revision, input.values, "save")),
},
reset: {
contract: rpc.reset,
handle: (input: z.output<typeof rpc.reset.input>) =>
this.serial(() => this.write(definition, input.revision, {}, "reset")),
},
};
}
private serial<T>(work: () => Promise<T>): Promise<T> {
const pending = this.queue.then(work);
this.queue = pending.catch(() => undefined);
return pending;
}
private async stored(id: string) {
try {
const raw = await readFile(path.join(this.directory, `${id}.json`), "utf8");
return { raw, revision: revisionOf(raw) };
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
return { raw: null, revision: "missing" };
throw error;
}
}
private async read(
definition: SettingsDefinition,
): Promise<z.output<ReturnType<typeof settingsRpc>["read"]["output"]>> {
const stored = await this.stored(definition.id);
try {
const envelope = stored.raw === null ? null : envelopeSchema.parse(JSON.parse(stored.raw));
let values: unknown = envelope?.values ?? {};
if (envelope && envelope.version !== definition.version) {
if (envelope.version > definition.version)
throw new Error("Settings were saved by a newer plugin version");
if (!definition.migrate)
throw new Error(`Settings version ${envelope.version} requires a migration`);
values = await definition.migrate(values, envelope.version);
}
const parsed = z.json().parse(await definition.schema.parseAsync(values));
const revision =
envelope && envelope.version !== definition.version
? await this.persist(definition, parsed)
: stored.revision;
return { status: "ready", values: parsed, revision };
} catch (error) {
return { status: "invalid", revision: stored.revision, error: message(error) };
}
}
private async write(
definition: SettingsDefinition,
revision: string,
values: unknown,
intent: "save" | "reset",
): Promise<z.output<ReturnType<typeof settingsRpc>["write"]["output"]>> {
const stored = await this.stored(definition.id);
if (stored.revision !== revision)
return {
status: "conflict",
error: "Settings changed on another client. Reload before saving again.",
};
let parsed: z.output<ReturnType<typeof z.json>>;
try {
if (intent === "save" && stored.raw !== null) {
const envelope = envelopeSchema.parse(JSON.parse(stored.raw));
if (envelope.version !== definition.version)
throw new Error("Reload or reset settings before saving a different schema version");
}
parsed = z.json().parse(await definition.schema.parseAsync(values));
} catch (error) {
return { status: "invalid", error: message(error) };
}
return { status: "saved", values: parsed, revision: await this.persist(definition, parsed) };
}
private async persist(
definition: SettingsDefinition,
values: z.output<ReturnType<typeof z.json>>,
) {
await mkdir(this.directory, { recursive: true });
const target = path.join(this.directory, `${definition.id}.json`);
const temporary = `${target}.${randomUUID()}.tmp`;
const raw = JSON.stringify({ version: definition.version, values });
try {
await writeFile(temporary, raw, { mode: 0o600 });
await rename(temporary, target);
} finally {
await rm(temporary, { force: true });
}
this.changed(definition.id);
return revisionOf(raw);
}
}
+12 -1
View File
@@ -502,6 +502,7 @@ export interface SessionOptions {
disablePlugin(pluginId: string): Promise<import("@getpaseo/protocol/messages").PluginListItem>;
removePlugin(pluginId: string): Promise<void>;
subscribe(listener: (pluginId: string) => void): () => void;
subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void;
catalog(): Array<{ id: string; clientBundle: string }>;
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
};
@@ -2219,9 +2220,19 @@ export class Session {
pluginRuntime: SessionOptions["pluginRuntime"],
): (() => void) | null {
if (!pluginRuntime) return null;
return pluginRuntime.subscribe((pluginId) => {
const catalog = pluginRuntime.subscribe((pluginId) => {
this.emit({ type: "status", payload: { status: "plugin_catalog_changed", pluginId } });
});
const settings = pluginRuntime.subscribeSettings?.((pluginId, settingsId) => {
this.emit({
type: "status",
payload: { status: "plugin_settings_changed", pluginId, settingsId },
});
});
return () => {
catalog();
settings?.();
};
}
private dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise<void> | undefined {
@@ -1679,6 +1679,7 @@ export class VoiceAssistantWebSocketServer {
pluginLogs: true,
// COMPAT(pluginThemes): added in v0.5.0, remove gate after 2027-08-20.
pluginThemes: true,
pluginSettings: true,
pluginTimelineItems: true,
// COMPAT(skillManagement): added in v0.4.0, remove gate after 2027-08-16.
skillManagement: true,
@@ -889,9 +889,13 @@ describe("WorkspaceGitService checkout observation", () => {
});
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
await fetchSnapshotRead.promise;
// Observation setup also reads facts. Wait for the initial snapshot to be
// published before injecting an event that must produce a second refresh.
await vi.waitFor(() => {
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
expect(service.peekSnapshot(REPO_CWD)?.git.currentBranch).toBe("feature");
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
});
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
await vi.waitFor(() => {
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
+14
View File
@@ -0,0 +1,14 @@
# Settings example
Install this directory with `paseo plugin install /absolute/path/to/plugin-examples/settings`.
Open Settings → Plugins → settings-example → Display, or run **Configure agent monitor** in Command Center.
The select and switch save immediately. The title editor keeps a draft and its original revision,
so another client's save produces a conflict instead of overwriting its changes. Close and reopen
the editor to discard the draft and use the latest settings.
Every UI component is a plain named import. The custom preview shares the same values without
using a form wrapper. Values are shared by clients connected to the same host.
The same screen on [Android](screenshots/android.png), [Electron](screenshots/electron.png),
and [compact web](screenshots/web-compact.png).
@@ -0,0 +1,148 @@
import { useCallback, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { useSettings, type PluginSurfaceProps, type SettingsState } from "@getpaseo/plugin";
import {
SettingsAction,
SettingsCard,
SettingsInput,
SettingsRow,
SettingsSection,
SettingsSelect,
SettingsSwitch,
} from "@getpaseo/plugin/ui";
import { preferences } from "../shared/preferences";
const grouping = [
{ label: "Project", value: "project" },
{ label: "Workspace", value: "workspace" },
{ label: "None", value: "none" },
] as const;
type Preferences = Extract<SettingsState<typeof preferences.schema>, { status: "ready" }>;
function TitleEditor({ settings, onClose }: { settings: Preferences; onClose(): void }) {
// Keep the draft's revision so another client's save produces a conflict.
const [draft, setDraft] = useState(() => ({
values: settings.values,
revision: settings.revision,
}));
const changeTitle = useCallback(
(title: string) =>
setDraft((current) => ({ ...current, values: { ...current.values, title } })),
[],
);
const saveTitle = useCallback(async () => {
if (await settings.save(draft.values, draft.revision)) onClose();
}, [settings, draft, onClose]);
return (
<SettingsCard>
<SettingsInput
label="Monitor title"
initialValue={draft.values.title}
onChangeText={changeTitle}
disabled={settings.saving}
error={settings.saveError}
/>
<SettingsAction
label="Title changes"
actionLabel="Save title"
disabled={settings.saving}
onPress={saveTitle}
/>
<SettingsAction
label="Discard title changes"
actionLabel="Discard"
disabled={settings.saving}
onPress={onClose}
/>
</SettingsCard>
);
}
function DisplayControls({
settings,
theme,
}: {
settings: Preferences;
theme: PluginSurfaceProps["theme"];
}) {
const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]);
const [editingTitle, setEditingTitle] = useState(false);
const closeTitle = useCallback(() => {
setEditingTitle(false);
void settings.reload();
}, [settings]);
const toggleTitle = useCallback(() => setEditingTitle((value) => !value), []);
const changeGrouping = useCallback(
(groupBy: Preferences["values"]["groupBy"]) => {
void settings.save({ ...settings.values, groupBy }, settings.revision);
},
[settings],
);
const changeMetadata = useCallback(
(showMetadata: boolean) => {
void settings.save({ ...settings.values, showMetadata }, settings.revision);
},
[settings],
);
return (
<SettingsSection title="Display">
<SettingsCard>
<SettingsSelect
label="Group agents by"
value={settings.values.groupBy}
options={grouping}
disabled={settings.saving}
onValueChange={changeGrouping}
/>
<SettingsSwitch
label="Show metadata"
value={settings.values.showMetadata}
disabled={settings.saving}
onValueChange={changeMetadata}
/>
<SettingsAction
label="Monitor title"
actionLabel={editingTitle ? "Close editor" : "Edit title"}
disabled={settings.saving}
onPress={toggleTitle}
/>
</SettingsCard>
{editingTitle ? <TitleEditor settings={settings} onClose={closeTitle} /> : null}
{settings.saveError && !editingTitle ? (
<Text accessibilityRole="alert" style={style}>
{settings.saveError}
</Text>
) : null}
<SettingsRow label="Preview">
<View>
<Text style={style}>{settings.values.title}</Text>
<Text style={style}>Grouped by {settings.values.groupBy}</Text>
<Text style={style}>
{settings.values.showMetadata ? "Metadata visible" : "Metadata hidden"}
</Text>
</View>
</SettingsRow>
</SettingsSection>
);
}
export function DisplaySettings({ theme }: PluginSurfaceProps) {
const settings = useSettings(preferences);
const style = useMemo(() => ({ color: theme.colors.foreground }), [theme]);
if (settings.status === "loading") return <Text style={style}>Loading settings…</Text>;
if (settings.status !== "ready")
return (
<SettingsSection title="Display">
<Text style={style}>{settings.error}</Text>
<SettingsAction label="Try again" actionLabel="Reload" onPress={settings.reload} />
{settings.status === "invalid" ? (
<SettingsAction
label="Restore default settings"
actionLabel="Reset"
onPress={settings.reset}
/>
) : null}
</SettingsSection>
);
return <DisplayControls settings={settings} theme={theme} />;
}
+21
View File
@@ -0,0 +1,21 @@
import type { PluginClientContext } from "@getpaseo/plugin";
import { DisplaySettings } from "./client/display-settings";
export default function contribute(client: PluginClientContext) {
client.addSettingsScreen({
id: "display",
title: "Display",
icon: "SlidersHorizontal",
Component: DisplaySettings,
});
client.addCommandCenterItem({
id: "settings",
title: "Configure agent monitor",
icon: "Settings",
context: "global",
onSelect({ openSettings }) {
openSettings("display");
},
});
return () => {};
}
+7
View File
@@ -0,0 +1,7 @@
import type { PluginServerContext } from "@getpaseo/plugin";
import { preferences } from "./shared/preferences";
export default function contribute(server: PluginServerContext) {
server.registerSettings(preferences);
return () => {};
}
@@ -0,0 +1 @@
{ "id": "settings-example" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,13 @@
import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
export const preferences = defineSettings({
id: "display",
scope: "host",
version: 1,
schema: z.object({
groupBy: z.enum(["project", "workspace", "none"]).default("project"),
showMetadata: z.boolean().default(true),
title: z.string().trim().min(1, "Enter a title").max(40).default("Agent monitor"),
}),
});
+17 -16
View File
@@ -67,22 +67,23 @@ is a compile error.
Use this table as the complete registration checklist.
| Old registration and location | New registration and location |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` |
| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` |
| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` |
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` |
| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` |
| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` |
| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
| `ZodOutput<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` from `@getpaseo/plugin`; `RpcOutput` for return types |
| Old registration and location | New registration and location |
| --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `plugin.handle(contract, handler)` in the old root entry | `server.handle(contract, handler)` in `index.server.ts` |
| `plugin.addSurface(id, Component)` in the old root entry | `client.addSurface(id, Component)` in `index.client.tsx` |
| `plugin.addSidebarItem(item)` in the old root entry | `client.addSidebarItem(item)` in `index.client.tsx` |
| `plugin.addWorkspacePanel(panel)` in the old root entry | `client.addWorkspacePanel(panel)` in `index.client.tsx` |
| `plugin.addCommandCenterItem(item)` in the old root entry | `client.addCommandCenterItem(item)` in `index.client.tsx` |
| `plugin.addClientSlashCommand(command)` in the old root entry | `client.addSlashCommand(command)` in `index.client.tsx` |
| `plugin.addClientSide(fn)` in the old root entry | Delete the wrapper and move the body of `fn` into the default client entry function |
| `client.addComposerPill(pill)` inside the old client callback | `client.addComposerPill(pill)` inside `index.client.tsx` or an imported `client/` function |
| `plugin.addAttachmentSource(source)` in the old root entry | `client.addAttachmentSource(source)` in `index.client.tsx` |
| New settings screen contribution | `client.addSettingsScreen(screen)` in `index.client.tsx`; see [settings screens](reference#settings-screens) |
| `plugin.addTheme(theme)` in the old root entry | `client.addTheme(theme)` in `index.client.tsx` |
| `plugin.addTimelineTransformer(transformer)` in the old root entry | `client.addTimelineTransformer(transformer)` in `index.client.tsx` |
| `plugin.addTimelineRenderer(renderer)` in the old root entry | `client.addTimelineRenderer(renderer)` in `index.client.tsx` |
| `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin/server"` in shared files | `import { defineRpc, defineAttachmentSource } from "@getpaseo/plugin"` |
| `ZodOutput<typeof contract.input>` handler parameter types | `RpcInput<typeof contract>` from `@getpaseo/plugin`; `RpcOutput` for return types |
Import `PluginClientContext` in the client entry and `PluginServerContext` in the server entry.
Remove imports of the old context type. `@getpaseo/plugin/server` now exports only handler-side
+118 -3
View File
@@ -85,6 +85,7 @@ Paseo provides these modules to client code:
| Module | Use it for |
| ------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `@getpaseo/plugin` | Contribution contracts, `defineRpc`, `defineAttachmentSource`, `RpcInput`, `RpcOutput`, and data hooks |
| `@getpaseo/plugin/ui` | Named, composable settings components |
| `@getpaseo/plugin/react-native` | Paseo UI components and UI hooks |
| `@getpaseo/plugin/server` | Handler-only types such as `PluginHandlerContext` |
| `@tanstack/react-query` | Request state and caching |
@@ -136,9 +137,8 @@ for the whole project and hides the next mistake. Components import `openExterna
`window` themselves. `layout.platform` on surface and panel props carries the same value as
`Platform.OS` for rendering decisions.
There is no plugin storage API. Browser storage does not persist settings across Paseo clients.
There is also no general host navigation API: plugin code cannot open native Paseo routes. Command
Center callbacks can only open surfaces and panels registered by the same plugin.
Use the [settings API](#settings-screens) for typed host-scoped persistence across clients.
Use `openSettings`, `openSurface`, and `openPanel` for your own registered contributions.
### Server runtime
@@ -528,6 +528,121 @@ unpainted.
Themes need a host that supports them. A client released before `addTheme` cannot evaluate that client entry and reports
`client.addTheme is not a function`. Update the client.
## Settings screens
Register a component with `client.addSettingsScreen({ id, title, icon, Component })` in
`index.client.tsx`. It appears under **Settings → Plugins → your plugin** on that host.
`id` is unique within the installation; `icon` is a Lucide name. Registration returns an
idempotent remover, and plugin teardown removes remaining screens.
Call `client.openSettings(id)` or a Command Center callback's `openSettings(id)` to open one
of your own screens. Each installation has its own values and route, even when several hosts
install the same plugin.
The component receives `PluginSurfaceProps`. Paseo owns the header, back navigation, safe areas,
scrolling, and the centered settings column. Compact windows push a full-screen detail; wide
windows keep the settings sidebar. Render content inside that frame using React Native components.
A disabled or removed plugin leaves an unavailable screen with working Back navigation.
### Named UI components
Import settings components from `@getpaseo/plugin/ui`. They work with your own state and RPCs;
no form wrapper or storage binding is required.
```tsx
import { useState } from "react";
import { SettingsCard, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/ui";
export function DisplaySettings() {
const [visible, setVisible] = useState(true);
return (
<SettingsSection title="Display">
<SettingsCard>
<SettingsSwitch label="Show metadata" value={visible} onValueChange={setVisible} />
</SettingsCard>
</SettingsSection>
);
}
```
| Component | Props and behavior |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `SettingsGroup`, `SettingsSection` | Required `title`, `children`; optional `info` tooltip, `trailing` content, `testID`. Own section spacing and headings. |
| `SettingsCard` | `children`, optional `testID`. Owns the card surface and dividers between direct children. Give mapped rows stable React keys. |
| `SettingsRow` | Required `label`; optional `hint`, `error`, `children`, `testID`. Wrap any custom control or content. |
| `SettingsSwitch` | Row props plus required `value: boolean`, `onValueChange`; optional `disabled`. |
| `SettingsSelect` | Row props plus required string `value`, `options: { label, value }[]`, `onValueChange`; optional `disabled`. Uses Paseo's adaptive menu. |
| `SettingsInput` | Row props plus required `onChangeText`; optional `initialValue`, `placeholder`, `disabled`, `secureTextEntry`, `ref`. |
| `SettingsAction` | Row props plus required `actionLabel`, `onPress`; optional `disabled`. |
`SettingsInput` owns in-progress text. `initialValue` seeds it when mounted. Its ref exposes
`focus()`, `blur()`, `getText()`, and `replaceText(text)` for explicit programmatic changes.
Keep draft text outside persisted values until the user saves. Custom previews and controls
can sit beside or inside these components.
### Persisted values
Define a settings document in `shared/`:
```ts
import { defineSettings } from "@getpaseo/plugin";
import { z } from "zod";
export const preferences = defineSettings({
id: "display",
scope: "host",
version: 1,
schema: z.object({ showMetadata: z.boolean().default(true) }),
});
```
Register it with `server.registerSettings(preferences)` in `index.server.ts` before returning
cleanup. This server entry is required for built-in persistence; a screen using its own data
can remain client-only.
| Definition field | Contract |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | Lowercase identifier matching `[a-z][a-z0-9_-]*`; unique within the installation. |
| `scope` | Required `"host"`. All authorized clients of that host share the document. No per-user, device-local, or cross-host synchronization. |
| `version` | Required positive integer describing the schema, independent of the write revision. |
| `schema` | Zod schema for JSON values. Supply defaults so parsing `{}` produces a complete document. |
| `migrate(values, fromVersion)` | Optional synchronous or asynchronous conversion from an older stored version. Its output must pass the current schema. |
Call `useSettings(preferences)` in any contributed component. It returns a discriminated state:
| `status` | Available data |
| --------- | ------------------------------------------------------------------------ |
| `loading` | Read is pending; do not render default values as though they were saved. |
| `ready` | Typed `values` and an opaque `revision`. |
| `invalid` | `error` and `revision`; stored data is preserved. |
| `error` | `error` from the read/connection. |
Every state also exposes `saving`, `saveError`, and these actions:
- `save(values, revision): Promise<boolean>` validates and saves a complete document. Returns
`false` and sets `saveError` on validation, conflict, or transport failure; it does not throw.
- `reset(): Promise<boolean>` explicitly replaces the document with schema defaults, using the
revision loaded by the hook. It can recover invalid stored data.
- `reload(): Promise<void>` clears the save error and reads again. Your component owns its draft;
reloading does not discard that draft automatically.
For an immediate toggle, pass `{ ...settings.values, showMetadata }` and `settings.revision`
to `save`. For a draft editor, capture both the values and revision when opening it. Keep that
revision until a save succeeds or the user discards the draft. A stale revision rejects the
save, preserving both the draft and the newer saved values.
Writes are atomic and validated on the host. Connected clients receive updates without reloading
the plugin. Values survive daemon restart, plugin reload, disable, and updates. Removing an
installation deletes its settings. Reinstalling that ID starts from defaults.
A missing document uses schema defaults. Invalid data, failed migrations, and unsupported newer
versions produce `invalid` without silently resetting the file. Successful migrations persist
the new version once. These documents are ordinary host-side JSON, not a credential vault.
Settings RPCs use the existing `daemon.manage` permission for plugin execution.
See the complete [settings example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/settings)
for immediate controls, a draft editor with validation, custom content, and Command Center navigation.
## Workspace panels
Register one panel for workspace or agent context: