@botiverse/porthole 0.1.2 → 0.1.3

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.
Files changed (64) hide show
  1. package/dist/annotate/types.d.ts +120 -0
  2. package/dist/annotate/types.js +42 -0
  3. package/dist/annotate/validate.d.ts +11 -0
  4. package/dist/annotate/validate.js +156 -0
  5. package/dist/bridge/bridge.d.ts +4 -0
  6. package/dist/bridge/bridge.js +12 -1
  7. package/dist/compile/policy.d.ts +4 -3
  8. package/dist/compile/policy.js +7 -4
  9. package/dist/config/capabilities.d.ts +37 -4
  10. package/dist/config/capabilities.js +20 -0
  11. package/dist/conformance/clipboard.d.ts +2 -0
  12. package/dist/conformance/clipboard.js +167 -0
  13. package/dist/conformance/kit.d.ts +1 -1
  14. package/dist/conformance/probes.js +2 -0
  15. package/dist/guide/guide.js +8 -4
  16. package/dist/host/clipboard.d.ts +11 -0
  17. package/dist/host/clipboard.js +43 -0
  18. package/dist/host/create-host.js +141 -0
  19. package/dist/host/types.d.ts +24 -0
  20. package/dist/index.d.ts +6 -3
  21. package/dist/index.js +6 -2
  22. package/dist/mcp/adapter.js +10 -1
  23. package/dist/protocol/mcp-apps.d.ts +5 -0
  24. package/dist/protocol/mcp-apps.js +5 -0
  25. package/dist/protocol/shell-adapter.js +6 -2
  26. package/dist/react/dialog.js +2 -0
  27. package/dist/react/frame.d.ts +18 -0
  28. package/dist/react/frame.js +82 -2
  29. package/dist/react/index.d.ts +2 -1
  30. package/dist/react/index.js +2 -1
  31. package/dist/react/toast.d.ts +33 -0
  32. package/dist/react/toast.js +42 -0
  33. package/dist/shell/annotate.d.ts +20 -0
  34. package/dist/shell/annotate.js +669 -0
  35. package/dist/shell/document.js +4 -1
  36. package/dist/shell/plan.d.ts +2 -0
  37. package/dist/shell/plan.js +2 -1
  38. package/dist/shell/runtime.d.ts +4 -1
  39. package/dist/shell/runtime.js +38 -2
  40. package/package.json +1 -1
  41. package/src/annotate/types.ts +126 -0
  42. package/src/annotate/validate.ts +186 -0
  43. package/src/bridge/bridge.ts +13 -0
  44. package/src/compile/policy.ts +7 -4
  45. package/src/config/capabilities.ts +33 -3
  46. package/src/conformance/clipboard.ts +184 -0
  47. package/src/conformance/kit.ts +1 -1
  48. package/src/conformance/probes.ts +2 -0
  49. package/src/guide/guide.ts +19 -4
  50. package/src/host/clipboard.ts +41 -0
  51. package/src/host/create-host.ts +155 -0
  52. package/src/host/types.ts +25 -0
  53. package/src/index.ts +22 -0
  54. package/src/mcp/adapter.ts +10 -1
  55. package/src/protocol/mcp-apps.ts +6 -0
  56. package/src/protocol/shell-adapter.ts +11 -2
  57. package/src/react/dialog.tsx +2 -0
  58. package/src/react/frame.tsx +117 -1
  59. package/src/react/index.ts +9 -1
  60. package/src/react/toast.tsx +96 -0
  61. package/src/shell/annotate.ts +669 -0
  62. package/src/shell/document.ts +6 -1
  63. package/src/shell/plan.ts +4 -1
  64. package/src/shell/runtime.ts +38 -2
@@ -66,6 +66,8 @@ function words(request: ConfirmRequest): Words {
66
66
  return { heading: "Share this with the agent?", accept: "Share", detail: json(request.args) };
67
67
  case "readData":
68
68
  return { heading: "Let it read this?", accept: "Allow", detail: request.args.uri };
69
+ case "copyText":
70
+ return { heading: "Copy this text?", accept: "Copy", detail: request.args.text };
69
71
  default:
70
72
  return { heading: "Allow this?", accept: "Allow", detail: json(request.args) };
71
73
  }
@@ -14,8 +14,10 @@ import {
14
14
  type ReactElement,
15
15
  type ReactNode,
16
16
  } from "react";
17
+ import type { AnnotateMode, AnnotationMark, AnnotationReport } from "../annotate/types.ts";
17
18
  import type {
18
19
  CallContext,
20
+ CapabilityHandler,
19
21
  CapabilityHandlers,
20
22
  Confirm,
21
23
  ConfirmAnswer,
@@ -25,11 +27,13 @@ import type {
25
27
  } from "../bridge/types.ts";
26
28
  import type { CapabilityName } from "../config/capabilities.ts";
27
29
  import type { ColorScheme, PortholeConfig } from "../config/schema.ts";
30
+ import { writeClipboard } from "../host/clipboard.ts";
28
31
  import { createHost } from "../host/create-host.ts";
29
32
  import type { DisplayEvents, DisplayHandle, FrameOutcome } from "../host/types.ts";
30
33
  import type { InstanceLocator, InstanceRef } from "../isolation/types.ts";
31
34
  import { ConfirmDialog } from "./dialog.tsx";
32
35
  import { createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
36
+ import { CopyToast, type CopyNotice } from "./toast.tsx";
33
37
 
34
38
  /**
35
39
  * The React binding: a `<PortholeProvider>` near the root owns what is per page (which frames
@@ -50,6 +54,8 @@ interface PageState {
50
54
  readonly confirm?: Confirm;
51
55
  readonly hostInfo?: { readonly name: string; readonly version: string };
52
56
  readonly locator?: InstanceLocator;
57
+ /** The default `copyText`: copies on the host's page and shows the toast (P052). */
58
+ readonly copyText?: CapabilityHandler<"copyText">;
53
59
  }
54
60
 
55
61
  /** Without a provider: one registry for the page, and no one to confirm (gated calls refused). */
@@ -69,6 +75,8 @@ export interface PortholeProviderProps {
69
75
  readonly locator?: InstanceLocator;
70
76
  /** A class for the default dialog, to restyle it. */
71
77
  readonly dialogClassName?: string;
78
+ /** A class for the default copy toast, to restyle it. */
79
+ readonly toastClassName?: string;
72
80
  readonly children?: ReactNode;
73
81
  }
74
82
 
@@ -96,16 +104,26 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
96
104
  ),
97
105
  [],
98
106
  );
107
+ // The default copyText: the host's page writes the clipboard (in the frame's click, which
108
+ // activated this page too) and shows the person what went on it, or offers to copy it.
109
+ const [notice, setNotice] = useState<CopyNotice | null>(null);
110
+ const copyText = useCallback<CapabilityHandler<"copyText">>(async ({ text }, context) => {
111
+ const copied = await writeClipboard(text, globalThis.document);
112
+ setNotice({ text, copied, title: context.title });
113
+ return copied;
114
+ }, []);
115
+ const closeNotice = useCallback(() => setNotice(null), []);
99
116
  const { hostInfo, locator } = props;
100
117
  const value = useMemo<PageState>(
101
118
  () => ({
102
119
  live,
103
120
  heights,
104
121
  confirm,
122
+ copyText,
105
123
  ...(hostInfo === undefined ? {} : { hostInfo }),
106
124
  ...(locator === undefined ? {} : { locator }),
107
125
  }),
108
- [live, heights, confirm, hostInfo, locator],
126
+ [live, heights, confirm, copyText, hostInfo, locator],
109
127
  );
110
128
  const current = queue[0] ?? null;
111
129
  return (
@@ -125,6 +143,17 @@ export function PortholeProvider(props: PortholeProviderProps): ReactElement {
125
143
  }}
126
144
  />
127
145
  ) : null}
146
+ <CopyToast
147
+ notice={notice}
148
+ {...(props.toastClassName === undefined ? {} : { className: props.toastClassName })}
149
+ onClose={closeNotice}
150
+ onCopy={(failed) => {
151
+ // A click on the host's own page: the write has the gesture it needs now.
152
+ void writeClipboard(failed.text, globalThis.document).then((copied) =>
153
+ setNotice({ ...failed, copied }),
154
+ );
155
+ }}
156
+ />
128
157
  </Page.Provider>
129
158
  );
130
159
  }
@@ -186,6 +215,21 @@ export interface PortholeFrameProps {
186
215
  * restyle): the running frame reads them again.
187
216
  */
188
217
  readonly themeKey?: string | number;
218
+ /**
219
+ * Annotations, where the config declares `annotate` (P053): what the person may point at now
220
+ * (elements, text, or both), or null to stop. A picked element ends element picking: set it
221
+ * again to pick again.
222
+ */
223
+ readonly annotating?: AnnotateMode | null;
224
+ /** The anchors to mark in the frame (the host's comments). Keep it stable (memoized). */
225
+ readonly annotations?: readonly AnnotationMark[];
226
+ /** The mark to bring into view and make the active one; each new id focuses it. */
227
+ readonly focusedAnnotation?: string | null;
228
+ /**
229
+ * What the frame reported: a selection (an anchor and where it is), its clearing, a cancelled
230
+ * pick, where marks are. The content's word: UI hints, never permissions.
231
+ */
232
+ readonly onAnnotation?: (report: AnnotationReport) => void;
189
233
  /** Draws the placeholder instead of the default one. */
190
234
  readonly renderPlaceholder?: (props: PlaceholderProps) => ReactNode;
191
235
  readonly className?: string;
@@ -371,6 +415,9 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
371
415
  case "outcome":
372
416
  frame.onOutcome?.(detail as DisplayEvents["outcome"]);
373
417
  return;
418
+ case "annotation":
419
+ frame.onAnnotation?.(detail as DisplayEvents["annotation"]);
420
+ return;
374
421
  }
375
422
  },
376
423
  );
@@ -397,6 +444,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
397
444
  named(context),
398
445
  );
399
446
  }
447
+ // Copy buttons without a handler of the host's: the provider's, which shows a toast.
448
+ if (config.capabilities.copyText !== undefined && handlers.copyText === undefined) {
449
+ const fallback = page.copyText;
450
+ if (fallback !== undefined) {
451
+ handlers.copyText = (args, context) =>
452
+ fallback(args as { readonly text: string }, named(context));
453
+ }
454
+ }
400
455
  const asks = first.confirm !== undefined || page.confirm !== undefined;
401
456
  let display: DisplayHandle;
402
457
  try {
@@ -435,10 +490,14 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
435
490
  "refused",
436
491
  "muted",
437
492
  "outcome",
493
+ "annotation",
438
494
  ];
439
495
  for (const event of events) {
440
496
  display.on(event, (detail) => notify(event, detail));
441
497
  }
498
+ if (config.capabilities.annotate !== undefined) {
499
+ applyAnnotations(display, first);
500
+ }
442
501
  if (first.content !== undefined) {
443
502
  display.setContent(first.content, { partial: first.partial === true });
444
503
  }
@@ -455,6 +514,39 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
455
514
  handle.current?.setContent(content, { partial: partial === true });
456
515
  }
457
516
  }, [content, partial]);
517
+ // Annotations follow their props (a new display gets them all when it is made, above).
518
+ const annotates = config.capabilities.annotate !== undefined;
519
+ const { annotating, annotations, focusedAnnotation } = frame;
520
+ const mode =
521
+ annotating === undefined || annotating === null
522
+ ? ""
523
+ : JSON.stringify([annotating.elements ?? true, annotating.text ?? true]);
524
+ useEffect(() => {
525
+ const display = handle.current;
526
+ if (!annotates || display === null) {
527
+ return;
528
+ }
529
+ const wanted = mode === "" ? null : (JSON.parse(mode) as [boolean, boolean]);
530
+ attempt(
531
+ () =>
532
+ wanted === null
533
+ ? display.annotations.stop()
534
+ : display.annotations.start({ elements: wanted[0], text: wanted[1] }),
535
+ latest(),
536
+ );
537
+ }, [annotates, mode]);
538
+ useEffect(() => {
539
+ const display = handle.current;
540
+ if (annotates && display !== null) {
541
+ attempt(() => display.annotations.show(annotations ?? []), latest());
542
+ }
543
+ }, [annotates, annotations]);
544
+ useEffect(() => {
545
+ const display = handle.current;
546
+ if (annotates && display !== null && typeof focusedAnnotation === "string") {
547
+ attempt(() => display.annotations.focus(focusedAnnotation), latest());
548
+ }
549
+ }, [annotates, focusedAnnotation]);
458
550
  useEffect(() => {
459
551
  handle.current?.setTheme(scheme);
460
552
  }, [scheme]);
@@ -484,3 +576,27 @@ function Mount({ frame, page, initialHeightPx, placeholder, run }: MountProps):
484
576
  </div>
485
577
  );
486
578
  }
579
+
580
+ /** Runs an annotation call, telling the frame's `onError` what it threw (a bad anchor, say). */
581
+ function attempt(run: () => void, frame: PortholeFrameProps): void {
582
+ try {
583
+ run();
584
+ } catch (error) {
585
+ frame.onError?.(error instanceof Error ? error.message : String(error));
586
+ }
587
+ }
588
+
589
+ /** A new display's annotations, from the props as they are. */
590
+ function applyAnnotations(display: DisplayHandle, frame: PortholeFrameProps): void {
591
+ attempt(() => {
592
+ if (frame.annotations !== undefined && frame.annotations.length > 0) {
593
+ display.annotations.show(frame.annotations);
594
+ }
595
+ if (typeof frame.focusedAnnotation === "string") {
596
+ display.annotations.focus(frame.focusedAnnotation);
597
+ }
598
+ if (frame.annotating !== undefined && frame.annotating !== null) {
599
+ display.annotations.start(frame.annotating);
600
+ }
601
+ }, frame);
602
+ }
@@ -2,7 +2,7 @@
2
2
  * `@botiverse/porthole/react`: the thin React binding (TSX: a host's bundler compiles it, or the
3
3
  * package's `dist`). Peer: `react` (19.2+, for `useEffectEvent`).
4
4
  *
5
- * <PortholeProvider> // per page: live-frame cap, heights, the dialog
5
+ * <PortholeProvider> // per page: live-frame cap, heights, the dialog, the copy toast
6
6
  * <PortholeFrame config={widgetConfig} displayId={id} title={title} scheme={scheme}
7
7
  * content={code} partial={streaming} handlers={{ sendPrompt, openLink }}
8
8
  * theme={tokensFor} onError={report} />
@@ -17,4 +17,12 @@ export {
17
17
  type PortholeProviderProps,
18
18
  } from "./frame.tsx";
19
19
  export { ARM_DELAY_MS, ConfirmDialog, type ConfirmDialogProps } from "./dialog.tsx";
20
+ export {
21
+ CopyToast,
22
+ TOAST_CHARS,
23
+ TOAST_MS,
24
+ clipped,
25
+ type CopyNotice,
26
+ type CopyToastProps,
27
+ } from "./toast.tsx";
20
28
  export { admitFrame, createLiveFrames, type FrameState, type LiveFrames } from "./live.ts";
@@ -0,0 +1,96 @@
1
+ import { useEffect, type ReactElement } from "react";
2
+
3
+ /**
4
+ * The default copy notice (P052): when content's `copyText` copied something, the host's page
5
+ * shows the person what went on their clipboard, clipped, in a small toast outside every frame.
6
+ * If the write failed (no recent gesture), it says so and offers a Copy button: a click on the
7
+ * host's own page, which writes it. A host replaces it by passing its own `copyText` handler; it
8
+ * styles it through the `porthole-toast` class and data attributes.
9
+ */
10
+
11
+ /** How long a toast for a copy that worked stays. */
12
+ export const TOAST_MS = 5_000;
13
+ /** The most of the copied text a toast shows. */
14
+ export const TOAST_CHARS = 280;
15
+ const TOAST_LINES = 4;
16
+
17
+ export interface CopyNotice {
18
+ /** The whole text copied (or to copy). */
19
+ readonly text: string;
20
+ /** Whether it is on the clipboard. */
21
+ readonly copied: boolean;
22
+ /** The display's title, as the host named it. */
23
+ readonly title: string;
24
+ }
25
+
26
+ export interface CopyToastProps {
27
+ readonly notice: CopyNotice | null;
28
+ /** The person asked to copy it again (it failed), from this click. */
29
+ readonly onCopy: (notice: CopyNotice) => void;
30
+ readonly onClose: () => void;
31
+ readonly className?: string;
32
+ }
33
+
34
+ /** The text as the toast shows it: at most a few lines and `TOAST_CHARS` characters. */
35
+ export function clipped(text: string): { readonly shown: string; readonly clipped: boolean } {
36
+ const lines = text.split("\n");
37
+ let shown = lines.slice(0, TOAST_LINES).join("\n");
38
+ if (shown.length > TOAST_CHARS) {
39
+ shown = shown.slice(0, TOAST_CHARS);
40
+ }
41
+ return shown.length < text.length
42
+ ? { shown: `${shown}…`, clipped: true }
43
+ : { shown, clipped: false };
44
+ }
45
+
46
+ const STYLE = `.porthole-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:2147483647;color-scheme:light dark;color:CanvasText;background:Canvas;border:1px solid color-mix(in srgb,CanvasText 18%,transparent);border-radius:12px;padding:12px 14px;width:min(420px,calc(100vw - 32px));box-shadow:0 8px 28px rgb(0 0 0 / .25);font:inherit;font-size:.9em}
47
+ .porthole-toast p{margin:0 0 6px}
48
+ .porthole-toast [data-porthole-source]{opacity:.72}
49
+ .porthole-toast pre{margin:0;white-space:pre-wrap;overflow-wrap:anywhere;max-height:8em;overflow:hidden;padding:8px 10px;border-radius:8px;background:color-mix(in srgb,CanvasText 7%,transparent);font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
50
+ .porthole-toast menu{display:flex;justify-content:flex-end;gap:8px;margin:8px 0 0;padding:0}
51
+ .porthole-toast button{font:inherit;min-height:32px;padding:0 12px;border-radius:8px;border:1px solid color-mix(in srgb,CanvasText 25%,transparent);background:transparent;color:inherit;cursor:pointer}`;
52
+
53
+ export function CopyToast({ notice, onCopy, onClose, className }: CopyToastProps): ReactElement {
54
+ useEffect(() => {
55
+ if (notice === null || !notice.copied) {
56
+ return;
57
+ }
58
+ const timer = setTimeout(onClose, TOAST_MS);
59
+ return () => clearTimeout(timer);
60
+ }, [notice, onClose]);
61
+ const view = notice === null ? null : clipped(notice.text);
62
+ return (
63
+ <div
64
+ role="status"
65
+ aria-live="polite"
66
+ className={className === undefined ? "porthole-toast" : `porthole-toast ${className}`}
67
+ data-porthole-toast=""
68
+ data-porthole-copied={notice === null ? "" : String(notice.copied)}
69
+ hidden={notice === null}
70
+ >
71
+ <style>{STYLE}</style>
72
+ {notice === null || view === null ? null : (
73
+ <>
74
+ <p>
75
+ <strong>{notice.copied ? "Copied" : "Not copied yet"}</strong>{" "}
76
+ <span data-porthole-source="">{`from “${notice.title}”`}</span>
77
+ </p>
78
+ <pre data-porthole-copied-text="">{view.shown}</pre>
79
+ {view.clipped ? (
80
+ <p data-porthole-length="">{`${notice.text.length.toLocaleString("en-US")} characters in all`}</p>
81
+ ) : null}
82
+ <menu>
83
+ {notice.copied ? null : (
84
+ <button type="button" data-porthole-copy="" onClick={() => onCopy(notice)}>
85
+ Copy
86
+ </button>
87
+ )}
88
+ <button type="button" data-porthole-close="" onClick={onClose}>
89
+ {notice.copied ? "OK" : "Close"}
90
+ </button>
91
+ </menu>
92
+ </>
93
+ )}
94
+ </div>
95
+ );
96
+ }