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:smis a half-width tile,mdis full width, andlgis 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
statusis'loading', show a skeleton. When there is no value, show the error text and let the user retry withdata.refresh(). Also handle the signed-out state. - The assistant or the user picks the widget's
config, which is validated againstconfigSchema. Describe each field with.describe(), and mention the fields indescription("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:
| Member | Use |
|---|---|
host.components | Screen, 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, refreshAll | Live 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.haptics | tap, 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.componentsand theme tokens. No hard-coded colours, font sizes or spacing. - MUST keep all user-facing text in your
strings.tstable, picked withhost.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 issrc/components/icon.tsxin 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 (’).
Tools and schemas
How to design tools the model uses well. Covers naming, descriptions, inputs in zod (bundled plugins) or JSON Schema (MCP), honest effects, results that are data, and states the user can recover from.
Languages (i18n)
How a bundled plugin provides Chinese and English. Manifest strings get English versions in `i18n.en`, screen and widget text goes in a `defineStrings` table, dates go through the SDK formatters, and text for the model stays untranslated.