@guuey/chat 0.5.0 → 0.6.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.
Files changed (61) hide show
  1. package/dist/hitl.d.ts +58 -0
  2. package/dist/hitl.d.ts.map +1 -0
  3. package/dist/hitl.js +81 -0
  4. package/dist/index.d.ts +3 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +2 -1
  7. package/dist/native/components.d.ts +18 -2
  8. package/dist/native/components.d.ts.map +1 -1
  9. package/dist/native/components.js +49 -0
  10. package/dist/native/transcript.d.ts +1 -1
  11. package/dist/native/transcript.d.ts.map +1 -1
  12. package/dist/native/transcript.js +2 -2
  13. package/dist/native.d.ts +1 -1
  14. package/dist/native.d.ts.map +1 -1
  15. package/dist/native.js +1 -1
  16. package/dist/plan.d.ts +15 -0
  17. package/dist/plan.d.ts.map +1 -1
  18. package/dist/plan.js +211 -13
  19. package/dist/policy.d.ts +4 -0
  20. package/dist/policy.d.ts.map +1 -1
  21. package/dist/policy.js +2 -0
  22. package/dist/react/components.d.ts +42 -5
  23. package/dist/react/components.d.ts.map +1 -1
  24. package/dist/react/components.js +51 -1
  25. package/dist/react/guuey-chat.d.ts +62 -4
  26. package/dist/react/guuey-chat.d.ts.map +1 -1
  27. package/dist/react/guuey-chat.js +73 -8
  28. package/dist/react/transcript.d.ts +1 -1
  29. package/dist/react/transcript.d.ts.map +1 -1
  30. package/dist/react/transcript.js +2 -2
  31. package/dist/react/use-transcript.d.ts +21 -2
  32. package/dist/react/use-transcript.d.ts.map +1 -1
  33. package/dist/react/use-transcript.js +57 -3
  34. package/dist/react.d.ts +2 -2
  35. package/dist/react.d.ts.map +1 -1
  36. package/dist/react.js +1 -1
  37. package/dist/strings.d.ts +13 -0
  38. package/dist/strings.d.ts.map +1 -1
  39. package/dist/strings.js +8 -0
  40. package/dist/types.d.ts +146 -7
  41. package/dist/types.d.ts.map +1 -1
  42. package/package.json +5 -5
  43. package/src/corpus/README.md +13 -1
  44. package/src/corpus/__snapshots__/corpus.test.ts.snap +304 -0
  45. package/src/corpus/drive.ts +3 -3
  46. package/src/corpus/fixtures.ts +220 -1
  47. package/src/hitl.ts +103 -0
  48. package/src/index.ts +15 -0
  49. package/src/native/components.tsx +136 -1
  50. package/src/native/transcript.tsx +4 -3
  51. package/src/native.tsx +1 -0
  52. package/src/plan.ts +233 -20
  53. package/src/policy.ts +4 -0
  54. package/src/react/components.tsx +171 -8
  55. package/src/react/guuey-chat.tsx +143 -9
  56. package/src/react/transcript.tsx +4 -3
  57. package/src/react/use-transcript.ts +83 -5
  58. package/src/react.tsx +3 -1
  59. package/src/strings.ts +25 -0
  60. package/src/types.ts +152 -6
  61. package/styles.css +21 -0
@@ -8,7 +8,7 @@
8
8
  * THE STANDING RULE (see README.md): a new weird transcript found in
9
9
  * production becomes a fixture BEFORE its fix lands. The corpus only grows.
10
10
  */
11
- import type { AgEvent } from "@silverprotocol/core";
11
+ import { Reducer, type AgEvent } from "@silverprotocol/core";
12
12
  import type { InvokeTurnEvent } from "@guuey/agent-client";
13
13
  import type { TranscriptInputs } from "../types.js";
14
14
  import {
@@ -320,6 +320,99 @@ export function userSendFailure(): TranscriptInputs {
320
320
  });
321
321
  }
322
322
 
323
+ /**
324
+ * 19. persisted-plus-live — the turn-scoped fold composition (portal's
325
+ * persisted-overlay data model): a persisted prefix the session's Reducer
326
+ * never saw + a session fold covering the trailing turns, one settled (its
327
+ * card ALSO persisted — the dedupe case) and one live with a running tool.
328
+ */
329
+ export function persistedPlusLive(): TranscriptInputs {
330
+ const reducer = new Reducer();
331
+ let seq = 0;
332
+ const s = (): number => seq++;
333
+ const push = (events: AgEvent[]): void => {
334
+ for (const ev of events) reducer.push(ev);
335
+ };
336
+ // Session turn A (settled — turn.done carries the outcome):
337
+ push([
338
+ { type: "turn.start", threadId: "thread-corpus", turnId: "turn-a", seq: s() },
339
+ { type: "message.start", id: "msg-a", role: "assistant", turnId: "turn-a", threadId: "thread-corpus", seq: s() },
340
+ { type: "text.start", id: "ta", seq: s() },
341
+ { type: "text.delta", id: "ta", delta: "Booked the second option.", seq: s() },
342
+ { type: "text.end", id: "ta", seq: s() },
343
+ { type: "tool.start", toolCallId: "tA", name: "ggui_render", seq: s() },
344
+ {
345
+ type: "tool.done",
346
+ toolCallId: "tA",
347
+ content: [],
348
+ outcome: "ok",
349
+ uiData: { sessionId: GGUI_SLICE.sessionId, resourceUri: GGUI_RESOURCE_URI, action: "create" },
350
+ _meta: { [GGUI_META_KEY]: GGUI_SLICE },
351
+ seq: s(),
352
+ },
353
+ { type: "turn.done", turnId: "turn-a", outcome: { type: "success" }, seq: s() },
354
+ ]);
355
+ // Session turn B (live — no turn.done yet): one completed tool already in
356
+ // the fold, a second still active (the fold materializes a tool-call at
357
+ // `tool.done`; the ACTIVE call is the status line's job, not a row).
358
+ push([
359
+ { type: "turn.start", threadId: "thread-corpus", turnId: "turn-b", seq: s() },
360
+ { type: "message.start", id: "msg-b", role: "assistant", turnId: "turn-b", threadId: "thread-corpus", seq: s() },
361
+ { type: "tool.start", toolCallId: "tB", name: "load_booking", turnId: "turn-b", seq: s() },
362
+ { type: "tool.args.assembled", toolCallId: "tB", input: { id: "second" }, turnId: "turn-b", seq: s() },
363
+ {
364
+ type: "tool.done",
365
+ toolCallId: "tB",
366
+ content: [{ type: "text", text: "loaded" }],
367
+ outcome: "ok",
368
+ turnId: "turn-b",
369
+ seq: s(),
370
+ },
371
+ ]);
372
+ return {
373
+ result: reducer.result(),
374
+ assistantText: "",
375
+ status: "using-tool",
376
+ statusElapsedMs: 0,
377
+ activeTool: "check_calendar",
378
+ error: null,
379
+ prompts: [],
380
+ // The flat transcript: one PERSISTED turn the fold never saw, then the
381
+ // session turns (turn A's settled text row is present — the thread
382
+ // viewer's liveTail/refetch carries it; turn B is in flight, no row).
383
+ messages: [
384
+ { role: "user", text: "plan my trip" },
385
+ { role: "assistant", text: "Here are some options." },
386
+ { role: "user", text: "book the second one" },
387
+ { role: "assistant", text: "Booked the second option." },
388
+ { role: "user", text: "now move it to Thursday" },
389
+ ],
390
+ historyCards: [
391
+ // An OLD persisted card (pre-session) — must render.
392
+ {
393
+ seq: 1,
394
+ at: "2026-08-15T00:00:00Z",
395
+ cardSnapshot: {
396
+ parts: [
397
+ { type: "tool-result", toolCallId: "old1", content: [], uiData: { resourceUri: "ui://ggui/render/old/1" } },
398
+ ],
399
+ },
400
+ },
401
+ // Turn A's card, ALSO persisted by the read plane — the live fold's
402
+ // mount owns this identity; the snapshot must dedupe away.
403
+ {
404
+ seq: 5,
405
+ at: "2026-08-15T00:05:00Z",
406
+ cardSnapshot: {
407
+ parts: [
408
+ { type: "tool-result", toolCallId: "tA", content: [], uiData: { resourceUri: GGUI_RESOURCE_URI } },
409
+ ],
410
+ },
411
+ },
412
+ ],
413
+ };
414
+ }
415
+
323
416
  /** 17. stalled-then-adopted — #192: calm identical, debug carries the marker. */
324
417
  export function stalledThenAdopted(): { adopted: TranscriptInputs; streamed: TranscriptInputs } {
325
418
  const build = (): InvokeTurnEvent[] => {
@@ -336,3 +429,129 @@ export function stalledThenAdopted(): { adopted: TranscriptInputs; streamed: Tra
336
429
  streamed: driveTurn(build(), { userText: "move my booking" }),
337
430
  };
338
431
  }
432
+
433
+ /**
434
+ * 20. hitl-grant-modes (spec draft.2, silverprotocol#16) — a paused turn's
435
+ * PERSISTED ask declares three asker-scoped accept variants. Driven through
436
+ * the real Reducer: the declaration the card renders from is
437
+ * `turn.done.outcome:"paused".asks[]`, exactly what a late-joining client
438
+ * sees. Ids are deliberately arbitrary strings — display must key off
439
+ * label/description, never id semantics.
440
+ */
441
+ export function hitlGrantModes(): TranscriptInputs {
442
+ const reducer = new Reducer();
443
+ let seq = 0;
444
+ const s = (): number => seq++;
445
+ const events: AgEvent[] = [
446
+ { type: "turn.start", threadId: "thread-hitl", turnId: "turn-h", seq: s() },
447
+ { type: "message.start", id: "msg-h", role: "assistant", turnId: "turn-h", threadId: "thread-hitl", seq: s() },
448
+ { type: "text.start", id: "th", seq: s() },
449
+ { type: "text.delta", id: "th", delta: "I can remember this preference.", seq: s() },
450
+ { type: "text.end", id: "th", seq: s() },
451
+ {
452
+ type: "turn.done",
453
+ turnId: "turn-h",
454
+ outcome: {
455
+ type: "paused",
456
+ asks: [
457
+ {
458
+ askId: "ask-remember-1",
459
+ kind: "approval",
460
+ message: "Remember your seating preference?",
461
+ grantModes: [
462
+ { id: "m.durable", label: "Always", description: "Keep it for every future chat" },
463
+ { id: "m.scoped", label: "Just this chat" },
464
+ ],
465
+ requestState: "rs-bytes-1",
466
+ },
467
+ ],
468
+ },
469
+ seq: s(),
470
+ },
471
+ ];
472
+ for (const ev of events) reducer.push(ev);
473
+ return {
474
+ result: reducer.result(),
475
+ assistantText: "",
476
+ status: "ready",
477
+ statusElapsedMs: 0,
478
+ activeTool: null,
479
+ error: null,
480
+ prompts: [], // the test composes these via hitlPromptsFromFold + the answer ledger
481
+ messages: [{ role: "user", text: "book my usual seat" }],
482
+ };
483
+ }
484
+
485
+ /**
486
+ * 21. notice-rows (spec draft.2) — both arrival paths: a flat adapter
487
+ * notice (the portal self-hosted mapping) and a fold-borne framework
488
+ * notice (the claude informational promotion). Labeled, non-conversational,
489
+ * never agent-voiced; provenance shows only under debug.
490
+ */
491
+ export function noticeRows(): TranscriptInputs {
492
+ const reducer = new Reducer();
493
+ let seq = 0;
494
+ const s = (): number => seq++;
495
+ const events: AgEvent[] = [
496
+ { type: "turn.start", threadId: "thread-n", turnId: "turn-n", seq: s() },
497
+ {
498
+ type: "message.start",
499
+ id: "msg-n0",
500
+ role: "notice",
501
+ noticeSource: "framework",
502
+ turnId: "turn-n",
503
+ threadId: "thread-n",
504
+ seq: s(),
505
+ },
506
+ // The notice's text lands while it is the sole open message, so block
507
+ // routing is unambiguous; the assistant message opens after.
508
+ { type: "text.start", id: "tn0", seq: s() },
509
+ { type: "text.delta", id: "tn0", delta: "Model fell back to concise mode", seq: s() },
510
+ { type: "text.end", id: "tn0", seq: s() },
511
+ { type: "message.end", id: "msg-n0", turnId: "turn-n", seq: s() },
512
+ { type: "message.start", id: "msg-n1", role: "assistant", turnId: "turn-n", threadId: "thread-n", seq: s() },
513
+ { type: "text.start", id: "tn", seq: s() },
514
+ { type: "text.delta", id: "tn", delta: "Done.", seq: s() },
515
+ { type: "text.end", id: "tn", seq: s() },
516
+ { type: "turn.done", turnId: "turn-n", outcome: { type: "success" }, seq: s() },
517
+ ];
518
+ for (const ev of events) reducer.push(ev);
519
+ return {
520
+ result: reducer.result(),
521
+ assistantText: "",
522
+ status: "ready",
523
+ statusElapsedMs: 0,
524
+ activeTool: null,
525
+ error: null,
526
+ prompts: [],
527
+ messages: [
528
+ { role: "notice", text: "Session resumed on a new device", noticeSource: "adapter" },
529
+ { role: "user", text: "hi" },
530
+ { role: "assistant", text: "Done." },
531
+ ],
532
+ };
533
+ }
534
+
535
+ /**
536
+ * 22. promoted-view — guuey#204 "promote and reference": the mount a host
537
+ * stage shows chips in the transcript; every other mount stays full; no
538
+ * key (or a stale one) is byte-identical to today.
539
+ */
540
+ export function promotedView(): { inputs: TranscriptInputs; promotedKey: string } {
541
+ const s = seqSource();
542
+ const events: InvokeTurnEvent[] = [session, frame(boot(s), { status: "thinking" })];
543
+ for (const id of ["t1", "t2"] as const) {
544
+ events.push(
545
+ frame(
546
+ toolPart(s, id, "ggui_render", {
547
+ uiData: { uri: `ui://cards/${id}`, mimeType: "text/html", text: `<div>${id}</div>` },
548
+ content: [],
549
+ }),
550
+ { status: "using-tool", activeTool: "ggui_render" },
551
+ ),
552
+ );
553
+ }
554
+ events.push(doneEvent);
555
+ const inputs = driveTurn(events, { userText: "show me two cards" });
556
+ return { inputs, promotedKey: "view.t2" };
557
+ }
package/src/hitl.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * R10's AgJSON HITL half (spec draft.2, silverprotocol/typescript-sdk#16):
3
+ * lifting persisted asks out of the fold, and constructing validated
4
+ * answers.
5
+ *
6
+ * Two contract points are load-bearing here:
7
+ *
8
+ * - **Asks render from the PERSISTED declaration** — `AgPausedAsk` in
9
+ * `turn.done.outcome:"paused".asks[]`. The live `hitl.ask` event is
10
+ * live-only by spec (§1.3); a client that joins a thread late renders
11
+ * the paused record, so this module reads ONLY that record and both
12
+ * paths converge.
13
+ * - **Every answer is validated BEFORE dispatch** — `validateHitlAnswer`
14
+ * from core enforces required-iff-declared, echo-must-be-declared, and
15
+ * the `requestState` byte-echo MUST. `buildHitlAnswer` constructs
16
+ * answers that pass by construction and THROWS if they don't: a failed
17
+ * validation here is a kit bug, never a user-visible state.
18
+ *
19
+ * Answer states encode guuey's ratified dismissal ruling (#16): dismissal
20
+ * maps to `cancelled` (still-pending, re-askable — the card stays
21
+ * answerable), `declined` is the durable explicit deny.
22
+ */
23
+ import {
24
+ validateHitlAnswer,
25
+ type AgGrantMode,
26
+ type AgHitlAnswer,
27
+ type AgPausedAsk,
28
+ type AgReduceResult,
29
+ } from "@silverprotocol/core";
30
+ import type { HitlPromptInput } from "./types.js";
31
+
32
+ /** A host-side record of how an ask was answered (the assembler's ledger value). */
33
+ export interface HitlAnswerRecord {
34
+ status: "resolved" | "declined" | "cancelled";
35
+ /** Present iff `status === "resolved"` and the ask declared modes. */
36
+ grantModeId?: string;
37
+ }
38
+
39
+ /** A user action on a HITL card: pick a declared mode, decline, or dismiss. */
40
+ export type HitlPromptAction = { grantModeId: string } | "accept" | "decline" | "dismiss";
41
+
42
+ /**
43
+ * Display text for a grant mode: the asker's `label`, else the id as
44
+ * LITERAL text. Mode semantics are asker-scoped (normative, spec §7):
45
+ * falling back to the id displays identity, never interpreted meaning —
46
+ * a renderer must not special-case id strings.
47
+ */
48
+ export function grantModeDisplay(mode: AgGrantMode): string {
49
+ return mode.label ?? mode.id;
50
+ }
51
+
52
+ /**
53
+ * Lift every persisted ask in the fold into an R10 prompt input, merged
54
+ * with the host's answer ledger (keyed by askId). Pure; input order is the
55
+ * fold's turn order.
56
+ */
57
+ export function hitlPromptsFromFold(
58
+ result: AgReduceResult | null,
59
+ answers: Readonly<Record<string, HitlAnswerRecord>> = {},
60
+ ): HitlPromptInput[] {
61
+ if (result === null) return [];
62
+ const prompts: HitlPromptInput[] = [];
63
+ for (const turn of result.turns) {
64
+ if (turn.outcome?.type !== "paused") continue;
65
+ for (const ask of turn.outcome.asks) {
66
+ const answer = answers[ask.askId];
67
+ prompts.push({
68
+ id: ask.askId,
69
+ kind: "hitl",
70
+ ask,
71
+ state: answer?.status ?? "pending",
72
+ ...(answer?.grantModeId !== undefined ? { grantModeId: answer.grantModeId } : {}),
73
+ });
74
+ }
75
+ }
76
+ return prompts;
77
+ }
78
+
79
+ /**
80
+ * Construct the wire answer for a card action and validate it against the
81
+ * ask's own persisted record before anything dispatches it.
82
+ *
83
+ * `"accept"` is the plain-ask affirmative (valid only when the ask
84
+ * declared no modes — with a declaration, accepting IS picking a mode).
85
+ * A validation failure throws: the inputs came from the declaration
86
+ * itself, so a mismatch is a construction bug, not a user error.
87
+ */
88
+ export function buildHitlAnswer(ask: AgPausedAsk, action: HitlPromptAction): AgHitlAnswer {
89
+ const answer: AgHitlAnswer = {
90
+ askId: ask.askId,
91
+ status:
92
+ action === "decline" ? "declined" : action === "dismiss" ? "cancelled" : "resolved",
93
+ ...(typeof action === "object" ? { grantModeId: action.grantModeId } : {}),
94
+ // The pre-existing byte-echo MUST (spec §7): the answer carries the
95
+ // ask's requestState verbatim when one exists.
96
+ ...(ask.requestState !== undefined ? { requestState: ask.requestState } : {}),
97
+ };
98
+ const verdict = validateHitlAnswer(ask, answer);
99
+ if (!verdict.ok) {
100
+ throw new Error(`buildHitlAnswer constructed an invalid answer (${verdict.code}): ${verdict.message}`);
101
+ }
102
+ return answer;
103
+ }
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  * rendering, tests, and RN bundlers consume the view-model directly.
12
12
  */
13
13
  export {
14
+ newestViewKey,
14
15
  planTranscript,
15
16
  } from "./plan.js";
16
17
  export {
@@ -24,6 +25,13 @@ export {
24
25
  type ChatStrings,
25
26
  } from "./strings.js";
26
27
  export { transcriptInputsFromHistory } from "./history-inputs.js";
28
+ export {
29
+ buildHitlAnswer,
30
+ grantModeDisplay,
31
+ hitlPromptsFromFold,
32
+ type HitlAnswerRecord,
33
+ type HitlPromptAction,
34
+ } from "./hitl.js";
27
35
  export {
28
36
  DEFAULT_CHAT_THEME,
29
37
  GUUEY_CHAT_THEME,
@@ -32,6 +40,7 @@ export {
32
40
  resolveTheme,
33
41
  } from "./theme.js";
34
42
  export type {
43
+ ChatDebugEvent,
35
44
  CitationsItem,
36
45
  CodeItem,
37
46
  CompactionItem,
@@ -40,7 +49,12 @@ export type {
40
49
  ErrorItem,
41
50
  HistoryBoundaryItem,
42
51
  ItemKey,
52
+ HitlPromptInput,
53
+ HitlPromptItem,
43
54
  MediaItem,
55
+ NoticeItem,
56
+ ProfilePromptInput,
57
+ ProfilePromptItem,
44
58
  PromptItem,
45
59
  PromptItemInput,
46
60
  ReasoningItem,
@@ -55,4 +69,5 @@ export type {
55
69
  UnknownItem,
56
70
  UserMessageItem,
57
71
  ViewMountItem,
72
+ ViewRefItem,
58
73
  } from "./types.js";
@@ -39,6 +39,7 @@ import type {
39
39
  HistoryBoundaryItem,
40
40
  ItemKey,
41
41
  MediaItem,
42
+ NoticeItem,
42
43
  PromptItem,
43
44
  ReasoningItem,
44
45
  StatusLineItem,
@@ -48,6 +49,7 @@ import type {
48
49
  UserMessageItem,
49
50
  TextItem,
50
51
  ViewMountItem,
52
+ ViewRefItem,
51
53
  } from "../types.js";
52
54
  import { Linking } from "react-native";
53
55
  import { NativeMarkdown } from "./markdown.js";
@@ -63,13 +65,21 @@ export interface NativeTranscriptItemContext {
63
65
  /** R0 failed-send retry. */
64
66
  onRetry?: (item: UserMessageItem) => void;
65
67
  /** R10 prompt actions — the host owns what accept/decline DO. */
66
- onPromptAction?: (item: PromptItem, action: "accept" | "decline" | "dismiss") => void;
68
+ onPromptAction?: (
69
+ item: PromptItem,
70
+ action: "accept" | "decline" | "dismiss" | { grantModeId: string },
71
+ ) => void;
67
72
  /** R11 action slots (sign-in / retry affordances). */
68
73
  onErrorAction?: (item: ErrorItem) => void;
69
74
  /** R6: locator mounts resolved by `useTranscript` ("expired" = failed). */
70
75
  resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
71
76
  /** R6: live phase reports wired back into the next plan. */
72
77
  onViewPhase: (key: ItemKey, phase: ViewHostPhase) => void;
78
+ /**
79
+ * guuey#204: a promoted-view reference chip was activated — the host
80
+ * focuses/reveals its stage. Absent ⇒ the chip renders as a label.
81
+ */
82
+ onViewRef?: (item: ViewRefItem) => void;
73
83
  }
74
84
 
75
85
  interface ItemProps<T> {
@@ -347,6 +357,39 @@ export function NativeView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode {
347
357
  );
348
358
  }
349
359
 
360
+ /**
361
+ * guuey#204: the "promote and reference" chip — a Pressable when the host
362
+ * wires `onViewRef`, a plain labeled row otherwise. Never a second mount.
363
+ */
364
+ export function NativeViewRef({ item, ctx }: ItemProps<ViewRefItem>): ReactNode {
365
+ const { tokens } = ctx;
366
+ const chipStyle = {
367
+ alignSelf: "flex-start" as const,
368
+ backgroundColor: tokens.palette.surface,
369
+ borderRadius: 999,
370
+ paddingHorizontal: tokens.pad,
371
+ paddingVertical: 6,
372
+ };
373
+ if (ctx.onViewRef === undefined) {
374
+ return (
375
+ <View accessibilityRole="text" style={chipStyle}>
376
+ <MutedText ctx={ctx}>{item.label}</MutedText>
377
+ </View>
378
+ );
379
+ }
380
+ const onViewRef = ctx.onViewRef;
381
+ return (
382
+ <Pressable
383
+ accessibilityRole="button"
384
+ accessibilityLabel={item.label}
385
+ onPress={() => onViewRef(item)}
386
+ style={chipStyle}
387
+ >
388
+ <MutedText ctx={ctx}>{item.label}</MutedText>
389
+ </Pressable>
390
+ );
391
+ }
392
+
350
393
  // ─── R7 ────────────────────────────────────────────────────────────────────
351
394
 
352
395
  /** Inline images allow https URLs and image/* base64 data — nothing else. */
@@ -442,6 +485,70 @@ export function NativeCitations({ item, ctx }: ItemProps<CitationsItem>): ReactN
442
485
 
443
486
  export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
444
487
  const { tokens } = ctx;
488
+ if (item.promptKind === "hitl") {
489
+ const s = ctx.strings;
490
+ if (item.state === "resolved" || item.state === "declined") {
491
+ return (
492
+ <MutedText ctx={ctx}>
493
+ {item.state === "resolved"
494
+ ? item.chosenModeLabel !== null
495
+ ? s.promptAnsweredWith(item.chosenModeLabel)
496
+ : s.promptAccept
497
+ : s.promptDeclinedRecord}
498
+ </MutedText>
499
+ );
500
+ }
501
+ // `cancelled` = guuey's re-askable dismissal: a record row that reopens
502
+ // to the actions on toggle (silverprotocol#16 ruling).
503
+ const answerable = item.state === "pending" || (item.state === "cancelled" && item.expanded);
504
+ return (
505
+ <View
506
+ accessibilityLiveRegion="polite"
507
+ accessible
508
+ style={{ backgroundColor: tokens.palette.surface, borderRadius: tokens.radius, padding: tokens.pad, gap: 8 }}
509
+ >
510
+ {item.state === "cancelled" && (
511
+ <Pressable accessibilityRole="button" accessibilityState={{ expanded: item.expanded }} onPress={() => ctx.onToggle(item.key)}>
512
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>{s.promptDismissed}</Text>
513
+ </Pressable>
514
+ )}
515
+ {answerable && (
516
+ <>
517
+ {item.message !== null && (
518
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
519
+ {item.message}
520
+ </Text>
521
+ )}
522
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 12 }}>
523
+ {item.grantModes.length === 0 ? (
524
+ <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "accept")}>
525
+ <Text style={{ color: tokens.palette.accent, fontWeight: "700", fontSize: tokens.fontSize }}>
526
+ {s.promptAccept}
527
+ </Text>
528
+ </Pressable>
529
+ ) : (
530
+ item.grantModes.map((mode) => (
531
+ <Pressable
532
+ key={mode.id}
533
+ accessibilityRole="button"
534
+ accessibilityHint={mode.description}
535
+ onPress={() => ctx.onPromptAction?.(item, { grantModeId: mode.id })}
536
+ >
537
+ <Text style={{ color: tokens.palette.accent, fontWeight: "700", fontSize: tokens.fontSize }}>
538
+ {mode.label ?? mode.id}
539
+ </Text>
540
+ </Pressable>
541
+ ))
542
+ )}
543
+ <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "decline")}>
544
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>{s.promptDecline}</Text>
545
+ </Pressable>
546
+ </View>
547
+ </>
548
+ )}
549
+ </View>
550
+ );
551
+ }
445
552
  if (item.state !== "pending") {
446
553
  return (
447
554
  <MutedText ctx={ctx}>
@@ -474,6 +581,22 @@ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
474
581
  );
475
582
  }
476
583
 
584
+ // ─── R16 ──────────────────────────────────────────────────────────────────
585
+
586
+ /** The notice row (spec draft.2) — labeled, non-conversational, never agent-voiced. */
587
+ export function NativeNotice({ item, ctx }: ItemProps<NoticeItem>): ReactNode {
588
+ const { tokens } = ctx;
589
+ return (
590
+ <View accessible accessibilityRole="text" style={{ paddingVertical: 4 }}>
591
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize - 2 }}>
592
+ {ctx.strings.noticeLabel}
593
+ {item.sourceLabel !== null ? ` · ${item.sourceLabel}` : ""}
594
+ {item.text !== "" ? ` — ${item.text}` : ""}
595
+ </Text>
596
+ </View>
597
+ );
598
+ }
599
+
477
600
  // ─── R11 ───────────────────────────────────────────────────────────────────
478
601
 
479
602
  export function NativeError({ item, ctx }: ItemProps<ErrorItem>): ReactNode {
@@ -576,10 +699,12 @@ export interface NativeTranscriptComponents {
576
699
  toolGroup: ComponentType<ItemProps<ToolGroupItem>>;
577
700
  dataResult: ComponentType<ItemProps<DataResultItem>>;
578
701
  view: ComponentType<ItemProps<ViewMountItem>>;
702
+ viewRef: ComponentType<ItemProps<ViewRefItem>>;
579
703
  media: ComponentType<ItemProps<MediaItem>>;
580
704
  code: ComponentType<ItemProps<CodeItem>>;
581
705
  citations: ComponentType<ItemProps<CitationsItem>>;
582
706
  prompt: ComponentType<ItemProps<PromptItem>>;
707
+ notice: ComponentType<ItemProps<NoticeItem>>;
583
708
  error: ComponentType<ItemProps<ErrorItem>>;
584
709
  history: ComponentType<ItemProps<HistoryBoundaryItem>>;
585
710
  compaction: ComponentType<ItemProps<CompactionItem>>;
@@ -595,10 +720,12 @@ export const nativeTranscriptComponents: NativeTranscriptComponents = {
595
720
  toolGroup: NativeToolGroup,
596
721
  dataResult: NativeDataResult,
597
722
  view: NativeView,
723
+ viewRef: NativeViewRef,
598
724
  media: NativeMedia,
599
725
  code: NativeCode,
600
726
  citations: NativeCitations,
601
727
  prompt: NativePrompt,
728
+ notice: NativeNotice,
602
729
  error: NativeError,
603
730
  history: NativeHistoryBoundary,
604
731
  compaction: NativeCompaction,
@@ -641,6 +768,10 @@ export function renderNativeItem(
641
768
  const C = components.view;
642
769
  return <C key={item.key} item={item} ctx={ctx} />;
643
770
  }
771
+ case "viewRef": {
772
+ const C = components.viewRef;
773
+ return <C key={item.key} item={item} ctx={ctx} />;
774
+ }
644
775
  case "media": {
645
776
  const C = components.media;
646
777
  return <C key={item.key} item={item} ctx={ctx} />;
@@ -657,6 +788,10 @@ export function renderNativeItem(
657
788
  const C = components.prompt;
658
789
  return <C key={item.key} item={item} ctx={ctx} />;
659
790
  }
791
+ case "notice": {
792
+ const C = components.notice;
793
+ return <C key={item.key} item={item} ctx={ctx} />;
794
+ }
660
795
  case "error": {
661
796
  const C = components.error;
662
797
  return <C key={item.key} item={item} ctx={ctx} />;
@@ -66,7 +66,7 @@ export type NativeTranscriptListProps = Pick<
66
66
  export interface NativeTranscriptProps
67
67
  extends Pick<
68
68
  NativeTranscriptItemContext,
69
- "onToggle" | "onRetry" | "onPromptAction" | "onErrorAction" | "resolvedMounts" | "onViewPhase"
69
+ "onToggle" | "onRetry" | "onPromptAction" | "onErrorAction" | "resolvedMounts" | "onViewPhase" | "onViewRef"
70
70
  > {
71
71
  plan: TranscriptPlan;
72
72
  /** Per-slot component overrides (spec §3's override column). */
@@ -93,6 +93,7 @@ export function NativeTranscript(props: NativeTranscriptProps): ReactNode {
93
93
  onErrorAction,
94
94
  resolvedMounts,
95
95
  onViewPhase,
96
+ onViewRef,
96
97
  } = props;
97
98
 
98
99
  const tokens = useMemo(() => resolveNativeTheme(theme, mode), [theme, mode]);
@@ -101,8 +102,8 @@ export function NativeTranscript(props: NativeTranscriptProps): ReactNode {
101
102
  [components],
102
103
  );
103
104
  const ctx: NativeTranscriptItemContext = useMemo(
104
- () => ({ strings, tokens, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase }),
105
- [strings, tokens, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase],
105
+ () => ({ strings, tokens, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, onViewRef }),
106
+ [strings, tokens, onToggle, onRetry, onPromptAction, onErrorAction, resolvedMounts, onViewPhase, onViewRef],
106
107
  );
107
108
 
108
109
  // Inverted-list data: reversed plan, newest at index 0 (the visual
package/src/native.tsx CHANGED
@@ -33,6 +33,7 @@ export {
33
33
  NativeToolGroup,
34
34
  NativeDataResult,
35
35
  NativeView,
36
+ NativeViewRef,
36
37
  NativeMedia,
37
38
  NativeCode,
38
39
  NativeCitations,