Extension guides
Examples and recipes
Small patterns for the parts every graphical renderer needs: loading host state, staying current, handling failures, and keeping durable behavior in Pi.
Load and subscribe to live state
Fetch an initial snapshot, subscribe to later events, and ignore a response that arrives after unmount. Depend on the stable channel functions rather than the changing context object.
function TaskCount({ context }: { context: RendererContext<typeof taskProtocol> }) {
const { call, on } = context.channel;
const [count, setCount] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
void call("state")
.then((state) => {
if (active) setCount(state.tasks.length);
})
.catch((reason) => {
if (active) setError(String(reason));
});
const unsubscribe = on("changed", (state) => {
setCount(state.tasks.length);
setError(null);
});
return () => {
active = false;
unsubscribe();
};
}, [call, on]);
if (error) return <span style={{ color: "var(--destructive)" }}>{error}</span>;
return <Badge variant="secondary">{count ?? "…"}</Badge>;
}Handle a failed method call
Method calls can fail validation, throw in the Pi process, time out, or lose their active chat. Handle the promise in the interaction that created it.
<Button
onClick={async () => {
try {
const next = await context.channel.call("increment", { by: 1 });
setCount(next.count);
} catch (error) {
context.actions.notify(`Unable to increment: ${String(error)}`, "error");
}
}}
>
Increment
</Button>Render every tool state
tools: {
"deploy.run": ({ call, result }) => {
const target = String(call.arguments.target ?? "unknown target");
if (!result) return <DeployStatus target={target} status="running" />;
if (result.isError) {
return <DeployStatus target={target} status="failed" detail={result.text} />;
}
return <DeployStatus target={target} status="complete" detail={result.text} />;
},
}Keep the target visible in all three states so the row remains stable while the result arrives. Do not present a failed result with the same treatment as a successful one.
Update a host-backed setting
Optimistically changing only React state creates a NativePi-only setting. Commit through the host and render the validated result it returns.
<SettingsSwitchRow
label="Confirm destructive queries"
checked={settings.confirmDestructive}
onChange={(checked) => {
void context.channel
.call("updateSettings", { confirmDestructive: checked })
.then(setSettings)
.catch((error) => context.actions.notify(String(error), "error"));
}}
/>Open a project file
async function openFinding(context: RendererContext, finding: Finding) {
try {
await context.actions.openFile(finding.file, {
line: finding.line,
column: finding.column,
});
} catch (error) {
context.actions.notify(`Unable to open ${finding.file}: ${String(error)}`, "error");
}
}Pass a project-relative path. Use openExternal only for HTTP or HTTPS URLs and revealFile when the file manager is more appropriate than an editor.
Keep a terminal path
pi.registerCommand("tasks", {
description: "Show project tasks",
handler: async (_args, context) => {
if (!host.connected) {
context.ui.notify(tasks.map((task) => task.title).join("
") || "No tasks", "info");
return;
}
context.ui.notify(`${tasks.length} tasks shown in NativePi`, "info");
},
});Complete example
The counter quickstart shows the manifest, shared protocol, Pi entry, renderer, and local installation together.