@botiverse/porthole 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +217 -0
- package/dist/bridge/bridge.d.ts +39 -0
- package/dist/bridge/bridge.js +184 -0
- package/dist/bridge/types.d.ts +67 -0
- package/dist/bridge/types.js +1 -0
- package/dist/compile/compile.d.ts +35 -0
- package/dist/compile/compile.js +30 -0
- package/dist/compile/policy.d.ts +55 -0
- package/dist/compile/policy.js +106 -0
- package/dist/config/capabilities.d.ts +154 -0
- package/dist/config/capabilities.js +63 -0
- package/dist/config/schema.d.ts +131 -0
- package/dist/config/schema.js +1 -0
- package/dist/config/validate.d.ts +20 -0
- package/dist/config/validate.js +230 -0
- package/dist/conformance/behavior.d.ts +2 -0
- package/dist/conformance/behavior.js +376 -0
- package/dist/conformance/index.d.ts +95 -0
- package/dist/conformance/index.js +4 -0
- package/dist/conformance/kit.d.ts +42 -0
- package/dist/conformance/kit.js +24 -0
- package/dist/conformance/oracles.d.ts +35 -0
- package/dist/conformance/oracles.js +77 -0
- package/dist/conformance/probes.d.ts +9 -0
- package/dist/conformance/probes.js +865 -0
- package/dist/conformance/served.d.ts +68 -0
- package/dist/conformance/served.js +311 -0
- package/dist/guide/examples.d.ts +70 -0
- package/dist/guide/examples.js +69 -0
- package/dist/guide/guide.d.ts +16 -0
- package/dist/guide/guide.js +261 -0
- package/dist/host/create-host.d.ts +8 -0
- package/dist/host/create-host.js +248 -0
- package/dist/host/locate.d.ts +8 -0
- package/dist/host/locate.js +11 -0
- package/dist/host/types.d.ts +95 -0
- package/dist/host/types.js +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +19 -0
- package/dist/isolation/served.d.ts +32 -0
- package/dist/isolation/served.js +97 -0
- package/dist/isolation/types.d.ts +87 -0
- package/dist/isolation/types.js +1 -0
- package/dist/mcp/adapter.d.ts +43 -0
- package/dist/mcp/adapter.js +327 -0
- package/dist/mcp/index.d.ts +27 -0
- package/dist/mcp/index.js +25 -0
- package/dist/mcp/proxy.d.ts +25 -0
- package/dist/mcp/proxy.js +106 -0
- package/dist/mcp/view.d.ts +31 -0
- package/dist/mcp/view.js +72 -0
- package/dist/platform/types.d.ts +44 -0
- package/dist/platform/types.js +1 -0
- package/dist/platform/web.d.ts +8 -0
- package/dist/platform/web.js +72 -0
- package/dist/presets/presets.d.ts +65 -0
- package/dist/presets/presets.js +125 -0
- package/dist/protocol/adapter.d.ts +73 -0
- package/dist/protocol/adapter.js +1 -0
- package/dist/protocol/jsonrpc.d.ts +43 -0
- package/dist/protocol/jsonrpc.js +50 -0
- package/dist/protocol/mcp-apps.d.ts +79 -0
- package/dist/protocol/mcp-apps.js +99 -0
- package/dist/protocol/shell-adapter.d.ts +5 -0
- package/dist/protocol/shell-adapter.js +228 -0
- package/dist/react/dialog.d.ts +20 -0
- package/dist/react/dialog.js +98 -0
- package/dist/react/frame.d.ts +75 -0
- package/dist/react/frame.js +221 -0
- package/dist/react/index.d.ts +13 -0
- package/dist/react/index.js +13 -0
- package/dist/react/live.d.ts +24 -0
- package/dist/react/live.js +57 -0
- package/dist/server/index.d.ts +27 -0
- package/dist/server/index.js +26 -0
- package/dist/server/keys.d.ts +19 -0
- package/dist/server/keys.js +78 -0
- package/dist/server/pages.d.ts +6 -0
- package/dist/server/pages.js +20 -0
- package/dist/server/proxy.d.ts +21 -0
- package/dist/server/proxy.js +107 -0
- package/dist/server/server.d.ts +7 -0
- package/dist/server/server.js +518 -0
- package/dist/server/tickets.d.ts +8 -0
- package/dist/server/tickets.js +28 -0
- package/dist/server/types.d.ts +97 -0
- package/dist/server/types.js +1 -0
- package/dist/shell/document.d.ts +28 -0
- package/dist/shell/document.js +84 -0
- package/dist/shell/plan.d.ts +35 -0
- package/dist/shell/plan.js +34 -0
- package/dist/shell/runtime.d.ts +28 -0
- package/dist/shell/runtime.js +832 -0
- package/dist/theme/variables.d.ts +73 -0
- package/dist/theme/variables.js +171 -0
- package/dist/transport/post-message.d.ts +9 -0
- package/dist/transport/post-message.js +38 -0
- package/dist/transport/types.d.ts +15 -0
- package/dist/transport/types.js +1 -0
- package/package.json +71 -1
- package/src/bridge/bridge.ts +256 -0
- package/src/bridge/types.ts +77 -0
- package/src/compile/compile.ts +68 -0
- package/src/compile/policy.ts +133 -0
- package/src/config/capabilities.ts +138 -0
- package/src/config/schema.ts +140 -0
- package/src/config/validate.ts +284 -0
- package/src/conformance/behavior.ts +430 -0
- package/src/conformance/index.ts +102 -0
- package/src/conformance/kit.ts +63 -0
- package/src/conformance/oracles.ts +126 -0
- package/src/conformance/probes.ts +1093 -0
- package/src/conformance/served.ts +472 -0
- package/src/guide/examples.ts +74 -0
- package/src/guide/guide.ts +424 -0
- package/src/host/create-host.ts +290 -0
- package/src/host/locate.ts +20 -0
- package/src/host/types.ts +95 -0
- package/src/index.ts +118 -0
- package/src/isolation/served.ts +127 -0
- package/src/isolation/types.ts +77 -0
- package/src/mcp/adapter.ts +441 -0
- package/src/mcp/index.ts +50 -0
- package/src/mcp/proxy.ts +117 -0
- package/src/mcp/view.ts +97 -0
- package/src/platform/types.ts +48 -0
- package/src/platform/web.ts +78 -0
- package/src/presets/presets.ts +193 -0
- package/src/protocol/adapter.ts +67 -0
- package/src/protocol/jsonrpc.ts +83 -0
- package/src/protocol/mcp-apps.ts +138 -0
- package/src/protocol/shell-adapter.ts +261 -0
- package/src/react/dialog.tsx +158 -0
- package/src/react/frame.tsx +422 -0
- package/src/react/index.ts +20 -0
- package/src/react/live.ts +76 -0
- package/src/server/index.ts +37 -0
- package/src/server/keys.ts +100 -0
- package/src/server/pages.ts +31 -0
- package/src/server/proxy.ts +131 -0
- package/src/server/server.ts +617 -0
- package/src/server/tickets.ts +30 -0
- package/src/server/types.ts +104 -0
- package/src/shell/document.ts +120 -0
- package/src/shell/plan.ts +66 -0
- package/src/shell/runtime.ts +832 -0
- package/src/theme/variables.ts +190 -0
- package/src/transport/post-message.ts +45 -0
- package/src/transport/types.ts +16 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Apps (`io.modelcontextprotocol/ui`, 2026-01-26): the method names Porthole speaks. The shell
|
|
3
|
+
* is an MCP Apps view of its host; content may also use the official App SDK directly, so the host
|
|
4
|
+
* side answers these whoever sends them. This module holds names and shapes only: the core's own
|
|
5
|
+
* adapter (the subset the shell speaks) and the `/mcp` adapter (full MCP Apps views) both use it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const MCP_APPS_PROTOCOL_VERSION = "2026-01-26";
|
|
9
|
+
|
|
10
|
+
export const MCP_APPS_METHODS = {
|
|
11
|
+
// View → host requests.
|
|
12
|
+
initialize: "ui/initialize",
|
|
13
|
+
message: "ui/message",
|
|
14
|
+
openLink: "ui/open-link",
|
|
15
|
+
downloadFile: "ui/download-file",
|
|
16
|
+
requestDisplayMode: "ui/request-display-mode",
|
|
17
|
+
updateModelContext: "ui/update-model-context",
|
|
18
|
+
callTool: "tools/call",
|
|
19
|
+
readResource: "resources/read",
|
|
20
|
+
// View → host notifications.
|
|
21
|
+
initialized: "ui/notifications/initialized",
|
|
22
|
+
sizeChanged: "ui/notifications/size-changed",
|
|
23
|
+
requestTeardown: "ui/notifications/request-teardown",
|
|
24
|
+
log: "notifications/message",
|
|
25
|
+
// Host → view notifications.
|
|
26
|
+
toolInput: "ui/notifications/tool-input",
|
|
27
|
+
toolInputPartial: "ui/notifications/tool-input-partial",
|
|
28
|
+
toolResult: "ui/notifications/tool-result",
|
|
29
|
+
toolCancelled: "ui/notifications/tool-cancelled",
|
|
30
|
+
hostContextChanged: "ui/notifications/host-context-changed",
|
|
31
|
+
// Host → view requests.
|
|
32
|
+
resourceTeardown: "ui/resource-teardown",
|
|
33
|
+
// Sandbox proxy (a second origin that relays between host and view).
|
|
34
|
+
sandboxProxyReady: "ui/notifications/sandbox-proxy-ready",
|
|
35
|
+
sandboxResourceReady: "ui/notifications/sandbox-resource-ready",
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
export type McpAppsMethod = (typeof MCP_APPS_METHODS)[keyof typeof MCP_APPS_METHODS];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What the shell's `tool-input(-partial)` carries: the content to draw. Porthole's own argument
|
|
42
|
+
* names, since the shell is the only view that reads them.
|
|
43
|
+
*/
|
|
44
|
+
export interface ShellToolInput {
|
|
45
|
+
readonly html: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The style variables MCP Apps defines (`McpUiStyleVariableKey`, 2026-01-26). The official App SDK
|
|
50
|
+
* refuses a host context whose `styles.variables` holds any other name ("Unrecognized key"), so a
|
|
51
|
+
* host sends exactly these there; Porthole's own names go under `PORTHOLE_STYLES` instead.
|
|
52
|
+
*/
|
|
53
|
+
export const MCP_APPS_STYLE_VARIABLES: readonly string[] = [
|
|
54
|
+
...["background", "text", "border", "ring"].flatMap((kind) =>
|
|
55
|
+
(kind === "background"
|
|
56
|
+
? [
|
|
57
|
+
"primary",
|
|
58
|
+
"secondary",
|
|
59
|
+
"tertiary",
|
|
60
|
+
"inverse",
|
|
61
|
+
"ghost",
|
|
62
|
+
"info",
|
|
63
|
+
"danger",
|
|
64
|
+
"success",
|
|
65
|
+
"warning",
|
|
66
|
+
"disabled",
|
|
67
|
+
]
|
|
68
|
+
: kind === "text"
|
|
69
|
+
? [
|
|
70
|
+
"primary",
|
|
71
|
+
"secondary",
|
|
72
|
+
"tertiary",
|
|
73
|
+
"inverse",
|
|
74
|
+
"ghost",
|
|
75
|
+
"info",
|
|
76
|
+
"danger",
|
|
77
|
+
"success",
|
|
78
|
+
"warning",
|
|
79
|
+
"disabled",
|
|
80
|
+
]
|
|
81
|
+
: kind === "border"
|
|
82
|
+
? [
|
|
83
|
+
"primary",
|
|
84
|
+
"secondary",
|
|
85
|
+
"tertiary",
|
|
86
|
+
"inverse",
|
|
87
|
+
"ghost",
|
|
88
|
+
"info",
|
|
89
|
+
"danger",
|
|
90
|
+
"success",
|
|
91
|
+
"warning",
|
|
92
|
+
"disabled",
|
|
93
|
+
]
|
|
94
|
+
: ["primary", "secondary", "inverse", "info", "danger", "success", "warning"]
|
|
95
|
+
).map((name) => `--color-${kind}-${name}`),
|
|
96
|
+
),
|
|
97
|
+
"--font-sans",
|
|
98
|
+
"--font-mono",
|
|
99
|
+
...["normal", "medium", "semibold", "bold"].map((weight) => `--font-weight-${weight}`),
|
|
100
|
+
...["xs", "sm", "md", "lg"].map((size) => `--font-text-${size}-size`),
|
|
101
|
+
...["xs", "sm", "md", "lg", "xl", "2xl", "3xl"].map((size) => `--font-heading-${size}-size`),
|
|
102
|
+
...["xs", "sm", "md", "lg"].map((size) => `--font-text-${size}-line-height`),
|
|
103
|
+
...["xs", "sm", "md", "lg", "xl", "2xl", "3xl"].map(
|
|
104
|
+
(size) => `--font-heading-${size}-line-height`,
|
|
105
|
+
),
|
|
106
|
+
...["xs", "sm", "md", "lg", "xl", "full"].map((size) => `--border-radius-${size}`),
|
|
107
|
+
"--border-width-regular",
|
|
108
|
+
...["hairline", "sm", "md", "lg"].map((size) => `--shadow-${size}`),
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The host context's own-prefixed field for the theme variables MCP Apps doesn't define (an
|
|
113
|
+
* accent, a chart palette): `{ variables }`. The shell applies them with the standard ones; a view
|
|
114
|
+
* that doesn't know the field ignores it (the host context is open to extra fields).
|
|
115
|
+
*/
|
|
116
|
+
export const PORTHOLE_STYLES = "porthole/styles";
|
|
117
|
+
|
|
118
|
+
/** The logger name the shell's error reports (`notifications/message`, level `error`) carry. */
|
|
119
|
+
export const SHELL_LOGGER = "porthole";
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The host context a view receives in `ui/initialize`'s result and in `host-context-changed`
|
|
123
|
+
* (the fields Porthole sets; the spec allows more, which views ignore).
|
|
124
|
+
*/
|
|
125
|
+
export interface HostContext {
|
|
126
|
+
readonly theme: "light" | "dark";
|
|
127
|
+
readonly styles: { readonly variables: Readonly<Record<string, string>> };
|
|
128
|
+
readonly displayMode: "inline" | "fullscreen" | "pip";
|
|
129
|
+
readonly availableDisplayModes: readonly ("inline" | "fullscreen" | "pip")[];
|
|
130
|
+
readonly containerDimensions?: { readonly maxWidth?: number; readonly maxHeight?: number };
|
|
131
|
+
readonly locale?: string;
|
|
132
|
+
readonly timeZone?: string;
|
|
133
|
+
readonly platform?: "web" | "desktop" | "mobile";
|
|
134
|
+
readonly deviceCapabilities?: { readonly touch?: boolean; readonly hover?: boolean };
|
|
135
|
+
/** Porthole's own theme variables, outside the spec's list (`PORTHOLE_STYLES`). */
|
|
136
|
+
readonly "porthole/styles"?: { readonly variables: Readonly<Record<string, string>> };
|
|
137
|
+
readonly [key: string]: unknown;
|
|
138
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import type { CapabilityName } from "../config/capabilities.ts";
|
|
2
|
+
import type { CallOutcome } from "../bridge/types.ts";
|
|
3
|
+
import type { AdapterSession, ProtocolAdapter, ShellConnection } from "./adapter.ts";
|
|
4
|
+
import {
|
|
5
|
+
JSON_RPC_ERRORS,
|
|
6
|
+
parseJsonRpc,
|
|
7
|
+
type JsonRpcId,
|
|
8
|
+
type JsonRpcRequest,
|
|
9
|
+
type JsonRpcResponse,
|
|
10
|
+
} from "./jsonrpc.ts";
|
|
11
|
+
import { MCP_APPS_METHODS as M, MCP_APPS_PROTOCOL_VERSION, type HostContext } from "./mcp-apps.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The core's adapter: the host side of the MCP Apps subset the shell speaks, which content using
|
|
15
|
+
* the official App SDK speaks too. Every message is checked before use; every power goes through
|
|
16
|
+
* the bridge. Content goes as `tool-input-partial` while it is written and `tool-input` once,
|
|
17
|
+
* after the view has initialized.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const LOG_ERRORS = new Set(["error", "critical", "alert", "emergency"]);
|
|
21
|
+
|
|
22
|
+
/** Requests that are capability calls, and how their params become the capability's args. */
|
|
23
|
+
const REQUESTS: Readonly<
|
|
24
|
+
Record<string, { capability: CapabilityName; args: (params: Record<string, unknown>) => unknown }>
|
|
25
|
+
> = {
|
|
26
|
+
[M.message]: {
|
|
27
|
+
capability: "sendPrompt",
|
|
28
|
+
args: (params) => ({
|
|
29
|
+
text: Array.isArray(params.content)
|
|
30
|
+
? params.content
|
|
31
|
+
.map((part: unknown) => {
|
|
32
|
+
const block = part as { type?: unknown; text?: unknown } | null;
|
|
33
|
+
return block !== null &&
|
|
34
|
+
typeof block === "object" &&
|
|
35
|
+
block.type === "text" &&
|
|
36
|
+
typeof block.text === "string"
|
|
37
|
+
? block.text
|
|
38
|
+
: "";
|
|
39
|
+
})
|
|
40
|
+
.join("")
|
|
41
|
+
: undefined,
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
[M.openLink]: { capability: "openLink", args: (params) => ({ url: params.url }) },
|
|
45
|
+
[M.downloadFile]: {
|
|
46
|
+
capability: "downloadFile",
|
|
47
|
+
args: (params) => ({ contents: params.contents }),
|
|
48
|
+
},
|
|
49
|
+
[M.callTool]: {
|
|
50
|
+
capability: "callTool",
|
|
51
|
+
args: (params) => ({ name: params.name, arguments: params.arguments }),
|
|
52
|
+
},
|
|
53
|
+
[M.readResource]: { capability: "readData", args: (params) => ({ uri: params.uri }) },
|
|
54
|
+
[M.updateModelContext]: {
|
|
55
|
+
capability: "setModelContext",
|
|
56
|
+
args: (params) => ({ content: params.content, structuredContent: params.structuredContent }),
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** What a capability's outcome looks like to the view. */
|
|
61
|
+
function answer(
|
|
62
|
+
capability: CapabilityName,
|
|
63
|
+
outcome: CallOutcome<unknown>,
|
|
64
|
+
): { result: unknown } | { error: { code: number; message: string } } {
|
|
65
|
+
const flagged =
|
|
66
|
+
capability === "sendPrompt" || capability === "openLink" || capability === "downloadFile";
|
|
67
|
+
if (!outcome.ok) {
|
|
68
|
+
return flagged
|
|
69
|
+
? { result: { isError: true } }
|
|
70
|
+
: { error: { code: -32000, message: `Refused (${outcome.refusal})` } };
|
|
71
|
+
}
|
|
72
|
+
if (flagged) {
|
|
73
|
+
return { result: outcome.value === true ? {} : { isError: true } };
|
|
74
|
+
}
|
|
75
|
+
return { result: outcome.value ?? {} };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What the host offers, from the declared capabilities (MCP Apps `hostCapabilities`). */
|
|
79
|
+
export function hostCapabilities(
|
|
80
|
+
declared: (name: CapabilityName) => boolean,
|
|
81
|
+
): Record<string, object> {
|
|
82
|
+
const offered: Record<string, object> = {};
|
|
83
|
+
if (declared("openLink")) {
|
|
84
|
+
offered.openLinks = {};
|
|
85
|
+
}
|
|
86
|
+
if (declared("sendPrompt")) {
|
|
87
|
+
offered.message = { text: {} };
|
|
88
|
+
}
|
|
89
|
+
if (declared("downloadFile")) {
|
|
90
|
+
offered.downloadFile = {};
|
|
91
|
+
}
|
|
92
|
+
if (declared("callTool")) {
|
|
93
|
+
offered.serverTools = {};
|
|
94
|
+
}
|
|
95
|
+
if (declared("readData")) {
|
|
96
|
+
offered.serverResources = {};
|
|
97
|
+
}
|
|
98
|
+
if (declared("setModelContext")) {
|
|
99
|
+
offered.updateModelContext = { text: {} };
|
|
100
|
+
}
|
|
101
|
+
if (declared("reportError")) {
|
|
102
|
+
offered.logging = {};
|
|
103
|
+
}
|
|
104
|
+
return offered;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const shellAdapter: ProtocolAdapter<ShellConnection> = {
|
|
108
|
+
name: "porthole-shell",
|
|
109
|
+
connect(session: AdapterSession): ShellConnection {
|
|
110
|
+
const { transport, bridge } = session;
|
|
111
|
+
let initialized = false;
|
|
112
|
+
let closed = false;
|
|
113
|
+
let drafted: string | null = null;
|
|
114
|
+
let content: { html: string; partial: boolean } | null = null;
|
|
115
|
+
let sentWhole = false;
|
|
116
|
+
let nextId = 1;
|
|
117
|
+
const pending = new Map<JsonRpcId, (response: JsonRpcResponse) => void>();
|
|
118
|
+
|
|
119
|
+
const respond = (
|
|
120
|
+
id: JsonRpcId,
|
|
121
|
+
body: { result: unknown } | { error: { code: number; message: string } },
|
|
122
|
+
) => transport.send({ jsonrpc: "2.0", id, ...body });
|
|
123
|
+
const notify = (method: string, params: Record<string, unknown>) =>
|
|
124
|
+
transport.send({ jsonrpc: "2.0", method, params });
|
|
125
|
+
|
|
126
|
+
const flush = () => {
|
|
127
|
+
if (!initialized || closed || content === null || sentWhole) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (content.partial) {
|
|
131
|
+
if (drafted !== content.html) {
|
|
132
|
+
drafted = content.html;
|
|
133
|
+
notify(M.toolInputPartial, { arguments: { html: content.html } });
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
sentWhole = true;
|
|
138
|
+
notify(M.toolInput, { arguments: { html: content.html } });
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onRequest = async (request: JsonRpcRequest) => {
|
|
142
|
+
const params = request.params ?? {};
|
|
143
|
+
if (request.method === M.initialize) {
|
|
144
|
+
respond(request.id, {
|
|
145
|
+
result: {
|
|
146
|
+
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
|
|
147
|
+
hostInfo: session.hostInfo,
|
|
148
|
+
hostCapabilities: hostCapabilities((name) => bridge.declared(name)),
|
|
149
|
+
hostContext: session.hostContext(),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (request.method === "ping") {
|
|
155
|
+
respond(request.id, { result: {} });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (request.method === M.requestDisplayMode) {
|
|
159
|
+
respond(request.id, { result: { mode: "inline" } });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const mapped = REQUESTS[request.method];
|
|
163
|
+
if (mapped === undefined) {
|
|
164
|
+
respond(request.id, {
|
|
165
|
+
error: {
|
|
166
|
+
code: JSON_RPC_ERRORS.methodNotFound,
|
|
167
|
+
message: `${request.method} isn't offered here`,
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const outcome = await bridge.call(mapped.capability, mapped.args(params));
|
|
173
|
+
if (!closed) {
|
|
174
|
+
respond(request.id, answer(mapped.capability, outcome));
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const onNotification = (method: string, params: Record<string, unknown>) => {
|
|
179
|
+
switch (method) {
|
|
180
|
+
case M.initialized:
|
|
181
|
+
if (!initialized) {
|
|
182
|
+
initialized = true;
|
|
183
|
+
session.emit({ type: "initialized" });
|
|
184
|
+
flush();
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
case M.sizeChanged:
|
|
188
|
+
void bridge.call("resize", { height: params.height, width: params.width });
|
|
189
|
+
return;
|
|
190
|
+
case M.log:
|
|
191
|
+
if (LOG_ERRORS.has(String(params.level))) {
|
|
192
|
+
const data = params.data;
|
|
193
|
+
void bridge.call("reportError", {
|
|
194
|
+
message:
|
|
195
|
+
typeof data === "string" ? data : JSON.stringify(data ?? null).slice(0, 2_000),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
case M.requestTeardown:
|
|
200
|
+
session.emit({ type: "teardown-requested" });
|
|
201
|
+
return;
|
|
202
|
+
default:
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const stop = transport.listen((raw) => {
|
|
208
|
+
const message = parseJsonRpc(raw);
|
|
209
|
+
if (message === null || closed) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if ("method" in message) {
|
|
213
|
+
if ("id" in message) {
|
|
214
|
+
void onRequest(message);
|
|
215
|
+
} else {
|
|
216
|
+
onNotification(message.method, message.params ?? {});
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
pending.get(message.id)?.(message);
|
|
221
|
+
pending.delete(message.id);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
setContent(html: string, options?: { readonly partial?: boolean }) {
|
|
226
|
+
if (sentWhole) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
content = { html, partial: options?.partial === true };
|
|
230
|
+
flush();
|
|
231
|
+
},
|
|
232
|
+
hostContextChanged(context: HostContext) {
|
|
233
|
+
if (initialized && !closed) {
|
|
234
|
+
notify(M.hostContextChanged, { ...context });
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
teardown() {
|
|
238
|
+
if (!initialized || closed) {
|
|
239
|
+
return Promise.resolve();
|
|
240
|
+
}
|
|
241
|
+
const id = `teardown-${nextId++}`;
|
|
242
|
+
return new Promise<void>((resolve) => {
|
|
243
|
+
const timer = setTimeout(() => {
|
|
244
|
+
pending.delete(id);
|
|
245
|
+
resolve();
|
|
246
|
+
}, 500);
|
|
247
|
+
pending.set(id, () => {
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
resolve();
|
|
250
|
+
});
|
|
251
|
+
transport.send({ jsonrpc: "2.0", id, method: M.resourceTeardown, params: {} });
|
|
252
|
+
});
|
|
253
|
+
},
|
|
254
|
+
close() {
|
|
255
|
+
closed = true;
|
|
256
|
+
stop();
|
|
257
|
+
pending.clear();
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
},
|
|
261
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState, type ReactElement } from "react";
|
|
2
|
+
import type { ConfirmRequest } from "../bridge/types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The default confirmation dialog (P019): drawn by the host's page, outside every frame, as a
|
|
6
|
+
* modal (the top layer covers the frames, so content can't draw over it or click it). It shows
|
|
7
|
+
* the call's whole effect (the prompt's full text, the link's full URL), the display's title as
|
|
8
|
+
* the host named it, Cancel focused, and ignores presses on its accept button for a moment after
|
|
9
|
+
* it opens (a click meant for the content mustn't land on it). Escape cancels. A host replaces it
|
|
10
|
+
* by passing its own `confirm`; it styles it through the `porthole-confirm` class and data
|
|
11
|
+
* attributes.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** How long the accept button ignores presses after the dialog opens. */
|
|
15
|
+
export const ARM_DELAY_MS = 500;
|
|
16
|
+
|
|
17
|
+
export interface ConfirmDialogProps {
|
|
18
|
+
/** The call to confirm, or null when nothing is asked. */
|
|
19
|
+
readonly request: ConfirmRequest | null;
|
|
20
|
+
readonly onAnswer: (accepted: boolean) => void;
|
|
21
|
+
readonly className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Words {
|
|
25
|
+
readonly heading: string;
|
|
26
|
+
readonly accept: string;
|
|
27
|
+
/** What the person is shown in full. */
|
|
28
|
+
readonly detail: string;
|
|
29
|
+
/** A line under it (the link's host). */
|
|
30
|
+
readonly note?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const json = (value: unknown): string => JSON.stringify(value, null, 2) ?? "";
|
|
34
|
+
|
|
35
|
+
function words(request: ConfirmRequest): Words {
|
|
36
|
+
switch (request.capability) {
|
|
37
|
+
case "sendPrompt":
|
|
38
|
+
return { heading: "Send this message?", accept: "Send", detail: request.args.text };
|
|
39
|
+
case "openLink": {
|
|
40
|
+
let host = "";
|
|
41
|
+
try {
|
|
42
|
+
host = new URL(request.args.url).host;
|
|
43
|
+
} catch {
|
|
44
|
+
host = "";
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
heading: "Open this link?",
|
|
48
|
+
accept: "Open link",
|
|
49
|
+
detail: request.args.url,
|
|
50
|
+
...(host === "" ? {} : { note: `It opens ${host}.` }),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
case "downloadFile":
|
|
54
|
+
return {
|
|
55
|
+
heading: "Download this file?",
|
|
56
|
+
accept: "Download",
|
|
57
|
+
detail: json(request.args.contents),
|
|
58
|
+
};
|
|
59
|
+
case "callTool":
|
|
60
|
+
return {
|
|
61
|
+
heading: "Run this tool?",
|
|
62
|
+
accept: "Run",
|
|
63
|
+
detail: `${request.args.name}\n${json(request.args.arguments)}`,
|
|
64
|
+
};
|
|
65
|
+
case "setModelContext":
|
|
66
|
+
return { heading: "Share this with the agent?", accept: "Share", detail: json(request.args) };
|
|
67
|
+
case "readData":
|
|
68
|
+
return { heading: "Let it read this?", accept: "Allow", detail: request.args.uri };
|
|
69
|
+
default:
|
|
70
|
+
return { heading: "Allow this?", accept: "Allow", detail: json(request.args) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const STYLE = `.porthole-confirm{color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:20px;width:min(440px,calc(100vw - 32px));font:inherit;box-shadow:0 12px 40px rgb(0 0 0 / .28)}
|
|
75
|
+
.porthole-confirm::backdrop{background:rgb(0 0 0 / .4)}
|
|
76
|
+
.porthole-confirm h2{font-size:1.05em;margin:0 0 4px}
|
|
77
|
+
.porthole-confirm [data-porthole-source],.porthole-confirm [data-porthole-note]{opacity:.72;font-size:.9em;margin:0 0 12px}
|
|
78
|
+
.porthole-confirm [data-porthole-detail]{white-space:pre-wrap;overflow-wrap:anywhere;max-height:40vh;overflow:auto;padding:10px 12px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);margin:0 0 12px;font:inherit}
|
|
79
|
+
.porthole-confirm menu{display:flex;justify-content:flex-end;gap:8px;margin:16px 0 0;padding:0}
|
|
80
|
+
.porthole-confirm button{font:inherit;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}
|
|
81
|
+
.porthole-confirm button[data-porthole-accept]{background:Highlight;background:AccentColor;color:HighlightText;color:AccentColorText;border-color:transparent}`;
|
|
82
|
+
|
|
83
|
+
export function ConfirmDialog({ request, onAnswer, className }: ConfirmDialogProps): ReactElement {
|
|
84
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
85
|
+
const cancel = useRef<HTMLButtonElement>(null);
|
|
86
|
+
// The request the accept button is armed for: a new one starts unarmed.
|
|
87
|
+
const [armedFor, setArmedFor] = useState<ConfirmRequest | null>(null);
|
|
88
|
+
const armed = request !== null && armedFor === request;
|
|
89
|
+
const heading = useId();
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const element = dialog.current;
|
|
93
|
+
if (element === null) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (request === null) {
|
|
97
|
+
if (element.open) {
|
|
98
|
+
element.close();
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (!element.open) {
|
|
103
|
+
element.showModal();
|
|
104
|
+
}
|
|
105
|
+
cancel.current?.focus();
|
|
106
|
+
const timer = setTimeout(() => setArmedFor(request), ARM_DELAY_MS);
|
|
107
|
+
return () => clearTimeout(timer);
|
|
108
|
+
}, [request]);
|
|
109
|
+
|
|
110
|
+
const said = request === null ? null : words(request);
|
|
111
|
+
return (
|
|
112
|
+
<dialog
|
|
113
|
+
ref={dialog}
|
|
114
|
+
className={className === undefined ? "porthole-confirm" : `porthole-confirm ${className}`}
|
|
115
|
+
aria-labelledby={heading}
|
|
116
|
+
data-porthole-confirm={request?.capability ?? ""}
|
|
117
|
+
onCancel={(event) => {
|
|
118
|
+
event.preventDefault();
|
|
119
|
+
if (request !== null) {
|
|
120
|
+
onAnswer(false);
|
|
121
|
+
}
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<style>{STYLE}</style>
|
|
125
|
+
{said === null || request === null ? null : (
|
|
126
|
+
<>
|
|
127
|
+
<h2 id={heading}>{said.heading}</h2>
|
|
128
|
+
<p data-porthole-source="">{`From “${request.display.title}”`}</p>
|
|
129
|
+
<div data-porthole-detail="">{said.detail}</div>
|
|
130
|
+
{said.note === undefined ? null : <p data-porthole-note="">{said.note}</p>}
|
|
131
|
+
<menu>
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
ref={cancel}
|
|
135
|
+
autoFocus
|
|
136
|
+
data-porthole-cancel=""
|
|
137
|
+
onClick={() => onAnswer(false)}
|
|
138
|
+
>
|
|
139
|
+
Cancel
|
|
140
|
+
</button>
|
|
141
|
+
<button
|
|
142
|
+
type="button"
|
|
143
|
+
data-porthole-accept=""
|
|
144
|
+
data-armed={String(armed)}
|
|
145
|
+
onClick={() => {
|
|
146
|
+
if (armed) {
|
|
147
|
+
onAnswer(true);
|
|
148
|
+
}
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{said.accept}
|
|
152
|
+
</button>
|
|
153
|
+
</menu>
|
|
154
|
+
</>
|
|
155
|
+
)}
|
|
156
|
+
</dialog>
|
|
157
|
+
);
|
|
158
|
+
}
|