feat(rpc): add validated desktop Pi RPC command palette

This commit is contained in:
2026-08-24 20:16:51 +02:00
parent 475f95ba70
commit 99084cc5a5
25 changed files with 2170 additions and 344 deletions
+58 -19
View File
@@ -1,5 +1,7 @@
import { useEffect, useMemo, useRef } from "react";
import type { Model, RuntimeView, TodoTask } from "../workspace/types";
import { rpcCommandCatalog, type RpcCommand } from "../commands/rpc";
import { CommandPalette } from "./CommandPalette";
export function messageText(message: RuntimeView["messages"][number]) {
if (typeof message.content === "string") return message.content;
@@ -13,6 +15,39 @@ export function messageText(message: RuntimeView["messages"][number]) {
.filter(Boolean)
.join("\n");
}
function TranscriptMessage({
message,
}: {
message: RuntimeView["messages"][number];
}) {
const role = message.role ?? "system";
if (role === "toolResult")
return (
<article className={`message ${role}`}>
<details className="tool-result">
<summary>Tool result · {message.toolName ?? "tool"}</summary>
<pre>{messageText(message)}</pre>
</details>
</article>
);
return (
<article className={`message ${role}`}>
<strong>{message.role ?? "message"}</strong>
<pre>{messageText(message)}</pre>
</article>
);
}
function pendingStatus(pending: RuntimeView["pendingSubmissions"][number]) {
if (pending.phase === "sending")
return pending.delivery === "follow_up"
? "Queueing follow-up…"
: "Sending prompt…";
return pending.delivery === "follow_up"
? "Follow-up queued · waiting for Pi"
: "Prompt accepted · waiting for Pi";
}
function todos(messages: RuntimeView["messages"]) {
const order: Record<TodoTask["status"], number> = {
in_progress: 0,
@@ -41,6 +76,8 @@ export function ConversationWorkspace({
onModel,
onThinking,
isSubmitting = false,
isAborting = false,
onCommand,
}: {
runtime: RuntimeView;
onDraft: (value: string) => void;
@@ -50,13 +87,16 @@ export function ConversationWorkspace({
onModel: (model: Model) => void;
onThinking: (level: string) => void;
isSubmitting?: boolean;
isAborting?: boolean;
onCommand: (command: RpcCommand) => void;
}) {
const ref = useRef<HTMLElement>(null);
const tasks = useMemo(() => todos(runtime.messages), [runtime.messages]);
const runtimeIsWorking =
runtime.summary.state === "streaming" ||
runtime.progress.phase === "working";
const isWorking =
isSubmitting ||
runtime.progress.phase === "working" ||
runtime.progress.phase === "recovering";
isSubmitting || runtimeIsWorking || runtime.progress.phase === "recovering";
useEffect(() => {
if (runtime.scroll.atBottom && ref.current)
ref.current.scrollTop = ref.current.scrollHeight;
@@ -108,13 +148,7 @@ export function ConversationWorkspace({
<p className="muted">No messages yet.</p>
)}
{runtime.messages.map((message, index) => (
<article
className={`message ${message.role ?? "system"}`}
key={index}
>
<strong>{message.role ?? "message"}</strong>
<pre>{messageText(message)}</pre>
</article>
<TranscriptMessage key={index} message={message} />
))}
{runtime.pendingSubmissions.map((pending) => (
<article
@@ -122,12 +156,7 @@ export function ConversationWorkspace({
key={pending.id}
>
<strong>
You{" "}
<span>
{pending.phase === "sending"
? "Sending…"
: "Sent · waiting for Pi"}
</span>
You <span>{pendingStatus(pending)}</span>
</strong>
<pre>{pending.text}</pre>
</article>
@@ -215,6 +244,11 @@ export function ConversationWorkspace({
{runtime.progress.tool && <small>{runtime.progress.tool}</small>}
</div>
<div className="composer">
<CommandPalette
draft={runtime.draft}
commands={rpcCommandCatalog}
onSelect={onCommand}
/>
<textarea
aria-label="Prompt"
value={runtime.draft}
@@ -229,9 +263,14 @@ export function ConversationWorkspace({
placeholder="Ask Pi…"
/>
<div className="composer-actions">
{runtime.state.isStreaming && (
<button type="button" className="quiet" onClick={onAbort}>
Abort
{runtimeIsWorking && (
<button
type="button"
className="quiet"
disabled={isAborting}
onClick={onAbort}
>
{isAborting ? "Aborting…" : "Abort"}
</button>
)}
<button