Skip to content

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.