PKU harness
Build a plugin

UI contributions

How a bundled plugin adds screens, Settings rows, tab content, dashboard widgets and slash commands through `contributes`, using the components the app passes in as HostUI.

This page explains how a bundled plugin adds its own UI to the app. It assumes you have read Bundled plugin. Plugin documents describe their widgets and screens as generative-UI JSON instead; see Declarative plugins.

A bundled plugin adds native UI without importing any app code. Each screen and widget receives an object called HostUI through its props. It holds the app's components, theme tokens, haptics, navigation and data hooks. Because the plugin draws everything with these, its UI looks like the rest of the app and keeps working when the app changes.

Everything a plugin adds is listed under contributes in definePlugin:

contributes: {
  screens:  { main: { title: '专注', component: FocusScreen } },
  settings: [{ screen: 'main', title: '专注', subtitle: '专注时段', icon: 'clock' }],
  widgets:  { today: { title: '今日专注', description: '…', sizes: ['sm', 'md'], component: FocusWidget } },
  commands: { focus: { description: '打开专注', aliases: ['专注'], run: (_i, { navigate }) => navigate('/p/focus/main') } },
  tabs:     [{ slot: 'schedule', screen: 'main' }],   // only for plugins that own a tab
},

Screens

interface ScreenContribution {
  title: string;
  component: ComponentType<PluginScreenProps>;
  presentation?: 'push' | 'modal';   // modal → /m/<plugin>/<screen> (forms); default push → /p/<plugin>/<screen>
}

A screen is a React component. By default it opens as a normal page (push). Set presentation: 'modal' for forms. The component receives PluginScreenProps: host, services, kv, invoke(toolId, input?), params (the query parameters of the route) and cloud?.

import type { PluginScreenProps } from '@pku-harness/sdk';
import { useState } from 'react';

export function EditScreen({ host, params, invoke }: PluginScreenProps) {
  const { SheetScreen, Field } = host.components;
  const [text, setText] = useState(params.text ?? '');
  return (
    <SheetScreen title="编辑" primary="保存" onPrimary={async () => { await invoke('memory.update', { id: params.id, text }); host.dismiss(); }}>
      <Field label="内容" value={text} onChangeText={setText} multiline />
    </SheetScreen>
  );
}

To open a screen, call host.navigate('/p/<plugin>/<screen>'), or host.navigate('/m/<plugin>/<screen>?id=…') for a modal. Close a modal with host.dismiss(). Use host.components.Screen as the root of a push screen, and SheetScreen as the root of a modal form.

Settings rows

interface SettingsContribution { screen: string; title: string; subtitle?: string; icon?: string }

A Settings row is a shortcut to one of your screens. Each row opens the screen it names. Apart from these rows, the plugin's page under Settings → Plugins lists its screens, widgets, tools and permissions.

Widgets

interface WidgetContribution {
  title: string;
  description: string;                 // for the agent: what it shows and what config does
  sizes?: ('sm' | 'md' | 'lg')[];
  defaultSize?: 'sm' | 'md' | 'lg';
  configSchema?: z.ZodType<Record<string, unknown>>;
  component: ComponentType<PluginWidgetProps>;  // host, services, kv, invoke, size, config, cloud?
}

A widget is a card on the dashboard. Keep these points in mind when you write one:

  • The dashboard draws the card's frame: the title, the background, how fresh the data is, and the long-press menu. You draw only the content. Adapt it to size: sm is a half-width tile, md is full width, and lg is a large feature card.
  • Load data with host.useToolData(tool, args, { ttlMinutes }) (see Dashboard). The app decides when to refresh, so don't poll.
  • Handle three states. While status is 'loading', show a skeleton. When there is no value, show the error text and let the user retry with data.refresh(). Also handle the signed-out state.
  • The assistant or the user picks the widget's config, which is validated against configSchema. Describe each field with .describe(), and mention the fields in description ("config.names limits it to certain canteens") so the assistant knows they exist.
  • Each widget is mounted inside an error boundary. If it crashes, the dashboard shows a notice in its place and the other widgets keep working.

Slash commands

type CommandContribution = {
  description: string;       // one line in the / menu
  aliases?: string[];        // e.g. Chinese names: ['余额']
  argHint?: string;          // placeholder; present = the command takes input
} & (
  | { prompt: string }       // sent as the user's message; {{input}} is replaced
  | { run: (input: string, ctx: { services: HostServices; navigate: (href: string) => void; locale: AppLocale }) => Promise<string | void> | string | void }
);

A slash command is an entry in the / menu of the chat composer. Command names are lowercase (^[a-z][a-z0-9-]*$). If two plugins use the same name, the app prefixes both with the plugin id, as /plugin:name.

A command does one of two things. A prompt command sends text to the assistant as if the user had typed it. A run command runs on the device; the string it returns appears as a notice in the chat and never reaches the model.

commands: {
  canteen: { description: '现在去哪个食堂', aliases: ['食堂', '吃饭'], argHint: '想吃什么 / 在哪(可空)', prompt: '现在哪些食堂还开着、人少?帮我挑一个{{input}}' },
  shuttle: { description: '下一班校车(本地计算)', aliases: ['校车'], argHint: '如 燕园到新燕园', run: (input, { locale }) => quickAnswer(input, Date.now(), locale) },
},

Tabs

The app has two tab slots, dashboard and schedule. Each is filled by the enabled plugin that contributes it, which today is the dashboard plugin and the schedule plugin. The Chat and Settings tabs belong to the app itself. Contribute a tab only if you are replacing one of those two plugins.

HostUI essentials

These are the members of host you will use most:

MemberUse
host.componentsScreen, SheetScreen, Text, Button, IconButton, ListGroup, ListRow, Chip, Surface, Glass, Stat, Ring, Sparkline, Field, DateTimeField, Switch, Checkbox, Segmented, Stepper, WeekdayPicker, SortableList, EmptyState, Skeleton, FadeIn, Icon, QRCode, GenUI, PluginWidget, Row, Column, Pressable, Hourglass
host.useTheme()Tokens: colors, spacing, radius, typography, glass, courseColor(i), contentWidth
host.useToolData, useWidgetData, useBindings, refreshAllLive data with the shared cache
host.useLocale()'zh-Hans' or 'en'
host.navigate, back, dismiss, ask(text)Navigation; ask switches to chat and sends a message
host.hapticstap, select, success, warning (respects user settings)
host.useNow(ms), formatAgo(ms)The current time, updated every ms; relative time
host.useSecureDisplay({ brightness })Block screenshots while mounted (payment codes)
host.useStoreVersion()Re-render when jobs, to-dos, widgets or the timetable change
host.clipboard?, host.shareLink?Optional; check before use

Full signatures are in SDK → ui.

Design rules

  • MUST use host.components and theme tokens. No hard-coded colours, font sizes or spacing.
  • MUST keep all user-facing text in your strings.ts table, picked with host.useLocale().
  • MUST use icon names from the host's icon set: card, wifi, book, bell, clock, location, sparkles, key, person, qr, warning, check, calendar, bus, food, wallet, library, room, school, ticket, medal, chart, search, globe, lock, … (the full list is src/components/icon.tsx in the app).
  • SHOULD give tappable custom content an accessibilityLabel.
  • Copy: Chinese text is short and has no exclamation marks. English text is in sentence case and uses curly apostrophes (’).

On this page