@guuey/chat 0.4.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 (69) 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 +110 -0
  8. package/dist/native/components.d.ts.map +1 -0
  9. package/dist/native/components.js +341 -0
  10. package/dist/native/markdown.d.ts +31 -0
  11. package/dist/native/markdown.d.ts.map +1 -0
  12. package/dist/native/markdown.js +69 -0
  13. package/dist/native/theme-native.d.ts +27 -0
  14. package/dist/native/theme-native.d.ts.map +1 -0
  15. package/dist/native/theme-native.js +28 -0
  16. package/dist/native/transcript.d.ts +48 -0
  17. package/dist/native/transcript.d.ts.map +1 -0
  18. package/dist/native/transcript.js +86 -0
  19. package/dist/native.d.ts +26 -0
  20. package/dist/native.d.ts.map +1 -0
  21. package/dist/native.js +27 -0
  22. package/dist/plan.d.ts +15 -0
  23. package/dist/plan.d.ts.map +1 -1
  24. package/dist/plan.js +211 -13
  25. package/dist/policy.d.ts +4 -0
  26. package/dist/policy.d.ts.map +1 -1
  27. package/dist/policy.js +2 -0
  28. package/dist/react/components.d.ts +42 -5
  29. package/dist/react/components.d.ts.map +1 -1
  30. package/dist/react/components.js +51 -1
  31. package/dist/react/guuey-chat.d.ts +62 -4
  32. package/dist/react/guuey-chat.d.ts.map +1 -1
  33. package/dist/react/guuey-chat.js +73 -8
  34. package/dist/react/transcript.d.ts +1 -1
  35. package/dist/react/transcript.d.ts.map +1 -1
  36. package/dist/react/transcript.js +2 -2
  37. package/dist/react/use-transcript.d.ts +21 -2
  38. package/dist/react/use-transcript.d.ts.map +1 -1
  39. package/dist/react/use-transcript.js +57 -3
  40. package/dist/react.d.ts +2 -2
  41. package/dist/react.d.ts.map +1 -1
  42. package/dist/react.js +1 -1
  43. package/dist/strings.d.ts +13 -0
  44. package/dist/strings.d.ts.map +1 -1
  45. package/dist/strings.js +8 -0
  46. package/dist/types.d.ts +146 -7
  47. package/dist/types.d.ts.map +1 -1
  48. package/package.json +18 -7
  49. package/src/corpus/README.md +13 -1
  50. package/src/corpus/__snapshots__/corpus.test.ts.snap +304 -0
  51. package/src/corpus/drive.ts +3 -3
  52. package/src/corpus/fixtures.ts +220 -1
  53. package/src/hitl.ts +103 -0
  54. package/src/index.ts +15 -0
  55. package/src/native/components.tsx +812 -0
  56. package/src/native/markdown.tsx +205 -0
  57. package/src/native/theme-native.ts +48 -0
  58. package/src/native/transcript.tsx +189 -0
  59. package/src/native.tsx +63 -0
  60. package/src/plan.ts +233 -20
  61. package/src/policy.ts +4 -0
  62. package/src/react/components.tsx +171 -8
  63. package/src/react/guuey-chat.tsx +143 -9
  64. package/src/react/transcript.tsx +4 -3
  65. package/src/react/use-transcript.ts +83 -5
  66. package/src/react.tsx +3 -1
  67. package/src/strings.ts +25 -0
  68. package/src/types.ts +152 -6
  69. package/styles.css +21 -0
@@ -0,0 +1,812 @@
1
+ /**
2
+ * The React Native per-category component kit (spec §3's override slots,
3
+ * §3.2's RN-parity obligation): one component per `DisplayItem` variant,
4
+ * each a THIN walk of its item — every rendering decision was already made
5
+ * by `planTranscript`; these translate decided items into RN primitives and
6
+ * never consult policy or invent copy. The override contract mirrors the
7
+ * web kit: `NativeTranscriptComponents` is the component map.
8
+ *
9
+ * Accessibility (spec §3.2 restated over RN):
10
+ * - every `expanded` toggle is a `Pressable` with `accessibilityRole`
11
+ * "button" + `accessibilityState.expanded`;
12
+ * - the status line and streaming text announce via
13
+ * `accessibilityLiveRegion="polite"` (Android) and are `accessible`
14
+ * grouped nodes for screen readers on both platforms;
15
+ * - R10 prompts are a live-region group so their appearance announces
16
+ * (RN has no document focus to move — the web kit's focus contract maps
17
+ * to announcement here);
18
+ * - there are NO decorative animations in the native defaults, so
19
+ * reduce-motion holds trivially at this tier (the transcript's scroll
20
+ * animation is where motion lives — see `transcript.tsx`).
21
+ *
22
+ * The R6 DEFAULT is the documented native default-gap: the kit ships
23
+ * WITHOUT a WebView dependency, so the default `view` renderer is a
24
+ * labeled placeholder (`strings.viewSandboxUnavailable`) — never blank —
25
+ * and a host app supplies its own WebView-based mount as the `view` slot
26
+ * override (portal's card machinery is the reference consumer).
27
+ */
28
+ import type { ComponentType, ReactNode } from "react";
29
+ import { Image, Pressable, Text, View } from "react-native";
30
+ import type { ResolvedViewMount, ViewHostPhase } from "@guuey/mcp-apps-host";
31
+ import type { ChatStrings } from "../strings.js";
32
+ import type {
33
+ CitationsItem,
34
+ CodeItem,
35
+ CompactionItem,
36
+ DataResultItem,
37
+ DisplayItem,
38
+ ErrorItem,
39
+ HistoryBoundaryItem,
40
+ ItemKey,
41
+ MediaItem,
42
+ NoticeItem,
43
+ PromptItem,
44
+ ReasoningItem,
45
+ StatusLineItem,
46
+ ToolGroupItem,
47
+ ToolItem,
48
+ UnknownItem,
49
+ UserMessageItem,
50
+ TextItem,
51
+ ViewMountItem,
52
+ ViewRefItem,
53
+ } from "../types.js";
54
+ import { Linking } from "react-native";
55
+ import { NativeMarkdown } from "./markdown.js";
56
+ import type { NativeChatTokens } from "./theme-native.js";
57
+
58
+ /** Everything a rendered native item may need beyond itself. */
59
+ export interface NativeTranscriptItemContext {
60
+ strings: ChatStrings;
61
+ /** The resolved theme tokens (see `resolveNativeTheme`). */
62
+ tokens: NativeChatTokens;
63
+ /** Flip an item's collapse state (the renderer owns override state). */
64
+ onToggle: (key: ItemKey) => void;
65
+ /** R0 failed-send retry. */
66
+ onRetry?: (item: UserMessageItem) => void;
67
+ /** R10 prompt actions — the host owns what accept/decline DO. */
68
+ onPromptAction?: (
69
+ item: PromptItem,
70
+ action: "accept" | "decline" | "dismiss" | { grantModeId: string },
71
+ ) => void;
72
+ /** R11 action slots (sign-in / retry affordances). */
73
+ onErrorAction?: (item: ErrorItem) => void;
74
+ /** R6: locator mounts resolved by `useTranscript` ("expired" = failed). */
75
+ resolvedMounts: ReadonlyMap<ItemKey, ResolvedViewMount | "expired">;
76
+ /** R6: live phase reports wired back into the next plan. */
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;
83
+ }
84
+
85
+ interface ItemProps<T> {
86
+ item: T;
87
+ ctx: NativeTranscriptItemContext;
88
+ }
89
+
90
+ /** A collapse toggle + body pair with the RN accessibility plumbing. */
91
+ function Collapsible({
92
+ itemKey,
93
+ expanded,
94
+ header,
95
+ ctx,
96
+ children,
97
+ }: {
98
+ itemKey: ItemKey;
99
+ expanded: boolean;
100
+ header: ReactNode;
101
+ ctx: NativeTranscriptItemContext;
102
+ children: ReactNode;
103
+ }): ReactNode {
104
+ const { tokens } = ctx;
105
+ return (
106
+ <View
107
+ style={{
108
+ backgroundColor: tokens.palette.surface,
109
+ borderRadius: tokens.radius,
110
+ paddingHorizontal: tokens.pad,
111
+ paddingVertical: tokens.pad - 4,
112
+ }}
113
+ >
114
+ <Pressable
115
+ accessibilityRole="button"
116
+ accessibilityState={{ expanded }}
117
+ onPress={() => ctx.onToggle(itemKey)}
118
+ style={{ flexDirection: "row", alignItems: "center", gap: 6 }}
119
+ >
120
+ <View style={{ flexShrink: 1, flexDirection: "row", alignItems: "center", gap: 6 }}>
121
+ {header}
122
+ </View>
123
+ <Text
124
+ accessibilityElementsHidden
125
+ style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize - 3 }}
126
+ >
127
+ {expanded ? "▾" : "▸"}
128
+ </Text>
129
+ </Pressable>
130
+ {expanded ? <View style={{ marginTop: 6, gap: 6 }}>{children}</View> : null}
131
+ </View>
132
+ );
133
+ }
134
+
135
+ function MutedText({ ctx, children }: { ctx: NativeTranscriptItemContext; children: ReactNode }): ReactNode {
136
+ const { tokens } = ctx;
137
+ return (
138
+ <Text
139
+ style={{
140
+ color: tokens.palette.inkMuted,
141
+ fontSize: tokens.fontSize - 2,
142
+ fontFamily: tokens.fontFamily,
143
+ }}
144
+ >
145
+ {children}
146
+ </Text>
147
+ );
148
+ }
149
+
150
+ // ─── R0 ────────────────────────────────────────────────────────────────────
151
+
152
+ export function NativeUserMessage({ item, ctx }: ItemProps<UserMessageItem>): ReactNode {
153
+ const { tokens } = ctx;
154
+ return (
155
+ <View style={{ alignItems: "flex-end", gap: 4, opacity: item.state === "sending" ? 0.6 : 1 }}>
156
+ <View
157
+ style={{
158
+ maxWidth: "82%",
159
+ backgroundColor: tokens.palette.ink,
160
+ borderRadius: tokens.radius,
161
+ paddingHorizontal: tokens.pad,
162
+ paddingVertical: tokens.pad - 2,
163
+ }}
164
+ >
165
+ {/* User text is QUOTED, never rendered — whitespace preserved. */}
166
+ <Text style={{ color: tokens.palette.canvas, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
167
+ {item.text}
168
+ </Text>
169
+ </View>
170
+ {item.state === "failed" ? (
171
+ <View accessibilityLiveRegion="polite" style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
172
+ <Text style={{ color: tokens.palette.error, fontSize: tokens.fontSize - 2 }}>
173
+ {ctx.strings.userCouldntSend}
174
+ </Text>
175
+ {item.retry ? (
176
+ <Pressable accessibilityRole="button" onPress={() => ctx.onRetry?.(item)}>
177
+ <Text style={{ color: tokens.palette.accent, fontSize: tokens.fontSize - 2, fontWeight: "600" }}>
178
+ {ctx.strings.userRetry}
179
+ </Text>
180
+ </Pressable>
181
+ ) : null}
182
+ </View>
183
+ ) : null}
184
+ </View>
185
+ );
186
+ }
187
+
188
+ // ─── R1 ────────────────────────────────────────────────────────────────────
189
+
190
+ export function NativeText({ item, ctx }: ItemProps<TextItem>): ReactNode {
191
+ const { tokens } = ctx;
192
+ const caret = item.streaming ? (
193
+ <Text style={{ color: tokens.palette.accent }}>{" ▍"}</Text>
194
+ ) : undefined;
195
+ return (
196
+ <View
197
+ accessibilityLiveRegion={item.streaming ? "polite" : "none"}
198
+ style={{
199
+ alignSelf: "flex-start",
200
+ maxWidth: "92%",
201
+ backgroundColor: tokens.palette.surface,
202
+ borderRadius: tokens.radius,
203
+ paddingHorizontal: tokens.pad,
204
+ paddingVertical: tokens.pad - 2,
205
+ gap: 4,
206
+ }}
207
+ >
208
+ {item.markdown ? (
209
+ <NativeMarkdown text={item.text} color={tokens.palette.ink} tokens={tokens} trailing={caret} />
210
+ ) : (
211
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
212
+ {item.text}
213
+ {caret}
214
+ </Text>
215
+ )}
216
+ {item.stopped ? <MutedText ctx={ctx}>{ctx.strings.stopped}</MutedText> : null}
217
+ </View>
218
+ );
219
+ }
220
+
221
+ // ─── R2 ────────────────────────────────────────────────────────────────────
222
+
223
+ export function NativeReasoning({ item, ctx }: ItemProps<ReasoningItem>): ReactNode {
224
+ return (
225
+ <Collapsible itemKey={item.key} expanded={item.expanded} ctx={ctx} header={<MutedText ctx={ctx}>{item.label}</MutedText>}>
226
+ <MutedText ctx={ctx}>{item.text}</MutedText>
227
+ </Collapsible>
228
+ );
229
+ }
230
+
231
+ // ─── R5 ────────────────────────────────────────────────────────────────────
232
+
233
+ export function NativeDataResult({ item, ctx }: ItemProps<DataResultItem>): ReactNode {
234
+ const { tokens } = ctx;
235
+ const bytes = ctx.strings.bytes(item.byteCount);
236
+ if (item.state === "empty") return <MutedText ctx={ctx}>{ctx.strings.noOutput}</MutedText>;
237
+ if (item.preview === null) return <MutedText ctx={ctx}>{bytes}</MutedText>;
238
+ return (
239
+ <View style={{ gap: 4 }}>
240
+ <View
241
+ style={{
242
+ maxHeight: 220, // R5's scroll-cap projected to a fixed clamp (native has no rem)
243
+ overflow: "hidden",
244
+ backgroundColor: tokens.palette.canvasMuted,
245
+ borderRadius: tokens.radius,
246
+ padding: tokens.pad - 4,
247
+ }}
248
+ >
249
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize - 3, fontFamily: tokens.monoFontFamily }}>
250
+ {item.preview}
251
+ </Text>
252
+ </View>
253
+ {item.showBytes ? <MutedText ctx={ctx}>{bytes}</MutedText> : null}
254
+ </View>
255
+ );
256
+ }
257
+
258
+ // ─── R3 ────────────────────────────────────────────────────────────────────
259
+
260
+ const TOOL_GLYPH: Record<ToolItem["state"], string> = {
261
+ running: "◌",
262
+ done: "✓",
263
+ failed: "✕",
264
+ orphaned: "–",
265
+ };
266
+
267
+ export function NativeTool({ item, ctx }: ItemProps<ToolItem>): ReactNode {
268
+ // R4's display-bearing rule: in calm this call's line lives in its view
269
+ // row's chrome ("via {tool}") — rendering it here too would double it.
270
+ if (item.attribution) return null;
271
+ const { tokens } = ctx;
272
+ const failed = item.state === "failed";
273
+ const header = (
274
+ <>
275
+ <Text style={{ color: failed ? tokens.palette.error : tokens.palette.inkMuted, fontSize: tokens.fontSize - 2 }}>
276
+ {TOOL_GLYPH[item.state]}
277
+ </Text>
278
+ <Text
279
+ style={{
280
+ color: failed ? tokens.palette.error : tokens.palette.inkMuted,
281
+ fontSize: tokens.fontSize - 2,
282
+ fontFamily: tokens.fontFamily,
283
+ flexShrink: 1,
284
+ }}
285
+ >
286
+ {item.title}
287
+ {item.state === "orphaned" ? ` — ${ctx.strings.toolDidntFinish}` : ""}
288
+ </Text>
289
+ </>
290
+ );
291
+ const expandable = item.argsPreview !== null || item.result !== null;
292
+ if (!expandable) {
293
+ return <View style={{ flexDirection: "row", alignItems: "center", gap: 6, paddingHorizontal: ctx.tokens.pad }}>{header}</View>;
294
+ }
295
+ return (
296
+ <Collapsible itemKey={item.key} expanded={item.expanded} ctx={ctx} header={header}>
297
+ {item.argsPreview !== null ? (
298
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize - 3, fontFamily: tokens.monoFontFamily }}>
299
+ {item.argsPreview}
300
+ </Text>
301
+ ) : null}
302
+ {item.result !== null ? <NativeDataResult item={item.result} ctx={ctx} /> : null}
303
+ </Collapsible>
304
+ );
305
+ }
306
+
307
+ // ─── R4 ────────────────────────────────────────────────────────────────────
308
+
309
+ export function NativeToolGroup({ item, ctx }: ItemProps<ToolGroupItem>): ReactNode {
310
+ const { tokens } = ctx;
311
+ return (
312
+ <Collapsible
313
+ itemKey={item.key}
314
+ expanded={item.expanded}
315
+ ctx={ctx}
316
+ header={
317
+ <>
318
+ <MutedText ctx={ctx}>{item.label}</MutedText>
319
+ {item.failureBadge !== null ? (
320
+ <Text style={{ color: tokens.palette.error, fontSize: tokens.fontSize - 3 }}>{item.failureBadge}</Text>
321
+ ) : null}
322
+ </>
323
+ }
324
+ >
325
+ {item.tools.map((tool) => (
326
+ <NativeTool key={tool.key} item={tool} ctx={ctx} />
327
+ ))}
328
+ </Collapsible>
329
+ );
330
+ }
331
+
332
+ // ─── R6 — the documented native default-gap ────────────────────────────────
333
+
334
+ export function NativeView({ item, ctx }: ItemProps<ViewMountItem>): ReactNode {
335
+ const { tokens } = ctx;
336
+ // The kit's native tier ships without a WebView dependency, so there is
337
+ // no default mount host — the `view` slot is a REQUIRED override on
338
+ // native (labeled here, never blank; portal's card machinery is the
339
+ // reference override). The expired/labeled states still render honestly.
340
+ const label =
341
+ item.phase === "expired"
342
+ ? ctx.strings.viewExpired
343
+ : (item.label ?? ctx.strings.viewSandboxUnavailable);
344
+ return (
345
+ <View
346
+ accessibilityRole="summary"
347
+ style={{
348
+ backgroundColor: tokens.palette.canvasMuted,
349
+ borderRadius: tokens.radius,
350
+ padding: tokens.pad,
351
+ gap: 4,
352
+ }}
353
+ >
354
+ <MutedText ctx={ctx}>{label}</MutedText>
355
+ {item.attribution !== null ? <MutedText ctx={ctx}>{item.attribution}</MutedText> : null}
356
+ </View>
357
+ );
358
+ }
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
+
393
+ // ─── R7 ────────────────────────────────────────────────────────────────────
394
+
395
+ /** Inline images allow https URLs and image/* base64 data — nothing else. */
396
+ function imageSrc(item: MediaItem): string | null {
397
+ const source = item.source;
398
+ if (source.type === "url" && /^https:\/\//i.test(source.url)) return source.url;
399
+ if (source.type === "base64" && /^image\//.test(source.mediaType)) {
400
+ return `data:${source.mediaType};base64,${source.data}`;
401
+ }
402
+ return null;
403
+ }
404
+
405
+ export function NativeMedia({ item, ctx }: ItemProps<MediaItem>): ReactNode {
406
+ const { tokens } = ctx;
407
+ if (item.presentation === "inline" && item.media === "image") {
408
+ const src = imageSrc(item);
409
+ if (src !== null) {
410
+ return (
411
+ <Image
412
+ accessibilityLabel={item.name ?? ""}
413
+ source={{ uri: src }}
414
+ resizeMode="contain"
415
+ style={{ width: "100%", height: 220, borderRadius: tokens.radius, backgroundColor: tokens.palette.canvasMuted }}
416
+ />
417
+ );
418
+ }
419
+ }
420
+ // Attachment chip: files, documents, audio (RN has no default audio
421
+ // element — a chip is the honest default), unloadable/oversized media.
422
+ return (
423
+ <View
424
+ style={{
425
+ alignSelf: "flex-start",
426
+ backgroundColor: tokens.palette.canvasMuted,
427
+ borderRadius: tokens.radius,
428
+ paddingHorizontal: tokens.pad,
429
+ paddingVertical: 4,
430
+ }}
431
+ >
432
+ <MutedText ctx={ctx}>{item.name ?? item.media}</MutedText>
433
+ </View>
434
+ );
435
+ }
436
+
437
+ // ─── R8 ────────────────────────────────────────────────────────────────────
438
+
439
+ export function NativeCode({ item, ctx }: ItemProps<CodeItem>): ReactNode {
440
+ const { tokens } = ctx;
441
+ return (
442
+ <View style={{ backgroundColor: tokens.palette.canvasMuted, borderRadius: tokens.radius, padding: tokens.pad - 2, gap: 4 }}>
443
+ <MutedText ctx={ctx}>{item.language}</MutedText>
444
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize - 3, fontFamily: tokens.monoFontFamily }}>
445
+ {item.code}
446
+ </Text>
447
+ </View>
448
+ );
449
+ }
450
+
451
+ // ─── R9 ────────────────────────────────────────────────────────────────────
452
+
453
+ /** Citation links navigate only to http(s) targets. */
454
+ function safeCitationUrl(url: string | null): string | null {
455
+ return url !== null && /^https?:\/\//i.test(url) ? url : null;
456
+ }
457
+
458
+ export function NativeCitations({ item, ctx }: ItemProps<CitationsItem>): ReactNode {
459
+ const { tokens } = ctx;
460
+ return (
461
+ <Collapsible itemKey={item.key} expanded={item.expanded} ctx={ctx} header={<MutedText ctx={ctx}>{item.label}</MutedText>}>
462
+ {item.sources.map((source, i) => {
463
+ const url = safeCitationUrl(source.url);
464
+ const label = source.title ?? source.url ?? "";
465
+ if (url === null) return <MutedText key={i} ctx={ctx}>{label}</MutedText>;
466
+ return (
467
+ <Pressable
468
+ key={i}
469
+ accessibilityRole="link"
470
+ onPress={() => {
471
+ void Linking.openURL(url).catch(() => {});
472
+ }}
473
+ >
474
+ <Text style={{ color: tokens.palette.accent, fontSize: tokens.fontSize - 2, textDecorationLine: "underline" }}>
475
+ {label}
476
+ </Text>
477
+ </Pressable>
478
+ );
479
+ })}
480
+ </Collapsible>
481
+ );
482
+ }
483
+
484
+ // ─── R10 ───────────────────────────────────────────────────────────────────
485
+
486
+ export function NativePrompt({ item, ctx }: ItemProps<PromptItem>): ReactNode {
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
+ }
552
+ if (item.state !== "pending") {
553
+ return (
554
+ <MutedText ctx={ctx}>
555
+ {item.promptKind}: {item.state}
556
+ </MutedText>
557
+ );
558
+ }
559
+ return (
560
+ <View
561
+ // RN has no document focus to move; the live region announces the ask
562
+ // (the web kit's focus contract, restated over the platform).
563
+ accessibilityLiveRegion="polite"
564
+ accessible
565
+ style={{ backgroundColor: tokens.palette.surface, borderRadius: tokens.radius, padding: tokens.pad, gap: 8 }}
566
+ >
567
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize, fontFamily: tokens.fontFamily }}>
568
+ {item.promptKind === "consent"
569
+ ? `${item.appId} requests ${item.requested} access`
570
+ : `Link your account to ${item.appId}`}
571
+ </Text>
572
+ <View style={{ flexDirection: "row", gap: 12 }}>
573
+ <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "accept")}>
574
+ <Text style={{ color: tokens.palette.accent, fontWeight: "700", fontSize: tokens.fontSize }}>Allow</Text>
575
+ </Pressable>
576
+ <Pressable accessibilityRole="button" onPress={() => ctx.onPromptAction?.(item, "decline")}>
577
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize }}>Decline</Text>
578
+ </Pressable>
579
+ </View>
580
+ </View>
581
+ );
582
+ }
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
+
600
+ // ─── R11 ───────────────────────────────────────────────────────────────────
601
+
602
+ export function NativeError({ item, ctx }: ItemProps<ErrorItem>): ReactNode {
603
+ const { tokens } = ctx;
604
+ return (
605
+ <View
606
+ accessibilityLiveRegion="assertive"
607
+ accessible
608
+ style={{
609
+ backgroundColor: tokens.palette.surface,
610
+ borderLeftWidth: 3,
611
+ borderLeftColor: tokens.palette.error,
612
+ borderRadius: tokens.radius,
613
+ padding: tokens.pad,
614
+ gap: 6,
615
+ }}
616
+ >
617
+ <Text style={{ color: tokens.palette.ink, fontSize: tokens.fontSize - 1, fontFamily: tokens.fontFamily }}>
618
+ {item.copy}
619
+ </Text>
620
+ {ctx.onErrorAction !== undefined && (item.family === "transient" || item.family === "auth") ? (
621
+ <Pressable accessibilityRole="button" onPress={() => ctx.onErrorAction?.(item)}>
622
+ <Text style={{ color: tokens.palette.accent, fontSize: tokens.fontSize - 1, fontWeight: "600" }}>
623
+ {item.family === "auth" ? ctx.strings.errorAuth : ctx.strings.userRetry}
624
+ </Text>
625
+ </Pressable>
626
+ ) : null}
627
+ {item.verbatim !== null ? (
628
+ <Text style={{ color: tokens.palette.inkMuted, fontSize: tokens.fontSize - 3, fontFamily: tokens.monoFontFamily }}>
629
+ {item.verbatim}
630
+ </Text>
631
+ ) : null}
632
+ </View>
633
+ );
634
+ }
635
+
636
+ // ─── R13 / R14 / R15 ───────────────────────────────────────────────────────
637
+
638
+ export function NativeHistoryBoundary({ item, ctx }: ItemProps<HistoryBoundaryItem>): ReactNode {
639
+ return (
640
+ <View accessibilityLiveRegion="polite" style={{ alignItems: "center", paddingVertical: 4 }}>
641
+ <MutedText ctx={ctx}>{item.label}</MutedText>
642
+ </View>
643
+ );
644
+ }
645
+
646
+ export function NativeCompaction({ item, ctx }: ItemProps<CompactionItem>): ReactNode {
647
+ return (
648
+ <View style={{ alignItems: "center", paddingVertical: 4 }}>
649
+ <MutedText ctx={ctx}>{item.label}</MutedText>
650
+ </View>
651
+ );
652
+ }
653
+
654
+ export function NativeUnknown({ item, ctx }: ItemProps<UnknownItem>): ReactNode {
655
+ return (
656
+ <Collapsible
657
+ itemKey={item.key}
658
+ expanded={item.expanded}
659
+ ctx={ctx}
660
+ header={
661
+ <MutedText ctx={ctx}>
662
+ {item.label} ({item.typeName})
663
+ </MutedText>
664
+ }
665
+ >
666
+ {item.raw !== null ? (
667
+ <Text style={{ color: ctx.tokens.palette.inkMuted, fontSize: ctx.tokens.fontSize - 3, fontFamily: ctx.tokens.monoFontFamily }}>
668
+ {JSON.stringify(item.raw, null, 2)}
669
+ </Text>
670
+ ) : (
671
+ <MutedText ctx={ctx}>{ctx.strings.bytes(item.byteSize)}</MutedText>
672
+ )}
673
+ </Collapsible>
674
+ );
675
+ }
676
+
677
+ // ─── R12 / §4 ──────────────────────────────────────────────────────────────
678
+
679
+ export function NativeStatus({ item, ctx }: ItemProps<StatusLineItem>): ReactNode {
680
+ const { tokens } = ctx;
681
+ return (
682
+ <View accessibilityLiveRegion="polite" accessible style={{ paddingHorizontal: tokens.pad, paddingVertical: 2 }}>
683
+ <MutedText ctx={ctx}>
684
+ {item.copy}
685
+ {item.detail !== null ? ` · ${item.detail}` : ""}
686
+ </MutedText>
687
+ </View>
688
+ );
689
+ }
690
+
691
+ // ─── The component map ─────────────────────────────────────────────────────
692
+
693
+ /** One component per §3 override slot — the native mirror of the web map. */
694
+ export interface NativeTranscriptComponents {
695
+ userMessage: ComponentType<ItemProps<UserMessageItem>>;
696
+ text: ComponentType<ItemProps<TextItem>>;
697
+ reasoning: ComponentType<ItemProps<ReasoningItem>>;
698
+ tool: ComponentType<ItemProps<ToolItem>>;
699
+ toolGroup: ComponentType<ItemProps<ToolGroupItem>>;
700
+ dataResult: ComponentType<ItemProps<DataResultItem>>;
701
+ view: ComponentType<ItemProps<ViewMountItem>>;
702
+ viewRef: ComponentType<ItemProps<ViewRefItem>>;
703
+ media: ComponentType<ItemProps<MediaItem>>;
704
+ code: ComponentType<ItemProps<CodeItem>>;
705
+ citations: ComponentType<ItemProps<CitationsItem>>;
706
+ prompt: ComponentType<ItemProps<PromptItem>>;
707
+ notice: ComponentType<ItemProps<NoticeItem>>;
708
+ error: ComponentType<ItemProps<ErrorItem>>;
709
+ history: ComponentType<ItemProps<HistoryBoundaryItem>>;
710
+ compaction: ComponentType<ItemProps<CompactionItem>>;
711
+ unknown: ComponentType<ItemProps<UnknownItem>>;
712
+ status: ComponentType<ItemProps<StatusLineItem>>;
713
+ }
714
+
715
+ export const nativeTranscriptComponents: NativeTranscriptComponents = {
716
+ userMessage: NativeUserMessage,
717
+ text: NativeText,
718
+ reasoning: NativeReasoning,
719
+ tool: NativeTool,
720
+ toolGroup: NativeToolGroup,
721
+ dataResult: NativeDataResult,
722
+ view: NativeView,
723
+ viewRef: NativeViewRef,
724
+ media: NativeMedia,
725
+ code: NativeCode,
726
+ citations: NativeCitations,
727
+ prompt: NativePrompt,
728
+ notice: NativeNotice,
729
+ error: NativeError,
730
+ history: NativeHistoryBoundary,
731
+ compaction: NativeCompaction,
732
+ unknown: NativeUnknown,
733
+ status: NativeStatus,
734
+ };
735
+
736
+ /** Dispatch one display item through the (possibly overridden) map. */
737
+ export function renderNativeItem(
738
+ item: DisplayItem,
739
+ components: NativeTranscriptComponents,
740
+ ctx: NativeTranscriptItemContext,
741
+ ): ReactNode {
742
+ switch (item.kind) {
743
+ case "user": {
744
+ const C = components.userMessage;
745
+ return <C key={item.key} item={item} ctx={ctx} />;
746
+ }
747
+ case "text": {
748
+ const C = components.text;
749
+ return <C key={item.key} item={item} ctx={ctx} />;
750
+ }
751
+ case "reasoning": {
752
+ const C = components.reasoning;
753
+ return <C key={item.key} item={item} ctx={ctx} />;
754
+ }
755
+ case "tool": {
756
+ const C = components.tool;
757
+ return <C key={item.key} item={item} ctx={ctx} />;
758
+ }
759
+ case "tool-group": {
760
+ const C = components.toolGroup;
761
+ return <C key={item.key} item={item} ctx={ctx} />;
762
+ }
763
+ case "data-result": {
764
+ const C = components.dataResult;
765
+ return <C key={item.key} item={item} ctx={ctx} />;
766
+ }
767
+ case "view": {
768
+ const C = components.view;
769
+ return <C key={item.key} item={item} ctx={ctx} />;
770
+ }
771
+ case "viewRef": {
772
+ const C = components.viewRef;
773
+ return <C key={item.key} item={item} ctx={ctx} />;
774
+ }
775
+ case "media": {
776
+ const C = components.media;
777
+ return <C key={item.key} item={item} ctx={ctx} />;
778
+ }
779
+ case "code": {
780
+ const C = components.code;
781
+ return <C key={item.key} item={item} ctx={ctx} />;
782
+ }
783
+ case "citations": {
784
+ const C = components.citations;
785
+ return <C key={item.key} item={item} ctx={ctx} />;
786
+ }
787
+ case "prompt": {
788
+ const C = components.prompt;
789
+ return <C key={item.key} item={item} ctx={ctx} />;
790
+ }
791
+ case "notice": {
792
+ const C = components.notice;
793
+ return <C key={item.key} item={item} ctx={ctx} />;
794
+ }
795
+ case "error": {
796
+ const C = components.error;
797
+ return <C key={item.key} item={item} ctx={ctx} />;
798
+ }
799
+ case "history-boundary": {
800
+ const C = components.history;
801
+ return <C key={item.key} item={item} ctx={ctx} />;
802
+ }
803
+ case "compaction": {
804
+ const C = components.compaction;
805
+ return <C key={item.key} item={item} ctx={ctx} />;
806
+ }
807
+ case "unknown": {
808
+ const C = components.unknown;
809
+ return <C key={item.key} item={item} ctx={ctx} />;
810
+ }
811
+ }
812
+ }