@vvedantb/native-ui 0.13.0 → 0.17.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 (108) hide show
  1. package/README.md +40 -19
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/chat-text.ts +13 -7
  36. package/src/ai/chat-thread.tsx +34 -27
  37. package/src/ai/choice-question.tsx +154 -35
  38. package/src/ai/code-block.tsx +189 -49
  39. package/src/ai/collapsible.tsx +19 -13
  40. package/src/ai/conversation.tsx +390 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +38 -1
  43. package/src/ai/message-action-sheet.tsx +4 -1
  44. package/src/ai/message.tsx +60 -31
  45. package/src/ai/prompt-input-menu.tsx +197 -0
  46. package/src/ai/prompt-input.tsx +301 -75
  47. package/src/ai/shimmer.tsx +4 -6
  48. package/src/ai/trace.tsx +54 -17
  49. package/src/ai/unified-diff.tsx +182 -0
  50. package/src/auth/GoogleSignInButton.tsx +6 -3
  51. package/src/auth/SignInScreen.tsx +11 -2
  52. package/src/brand/ProviderIcon.tsx +136 -0
  53. package/src/brand/index.ts +2 -0
  54. package/src/configureTheme.ts +39 -11
  55. package/src/controlStyles.ts +46 -30
  56. package/src/entries/gesture-handler.ts +4 -0
  57. package/src/entries/keyboard-controller.ts +4 -0
  58. package/src/entries/markdown.ts +1 -1
  59. package/src/entries/primitives.ts +24 -3
  60. package/src/entries/theme.ts +2 -1
  61. package/src/entries/video.ts +4 -0
  62. package/src/entries/webview.ts +6 -0
  63. package/src/floating.ts +25 -4
  64. package/src/haptics.ts +30 -4
  65. package/src/highlight.ts +302 -1
  66. package/src/index.ts +27 -5
  67. package/src/loadingGrid.ts +75 -0
  68. package/src/markdown/Markdown.tsx +234 -32
  69. package/src/markdown/MarkdownTable.tsx +188 -0
  70. package/src/markdown/tableText.ts +58 -0
  71. package/src/marketing/UpgradeToProCard.tsx +4 -3
  72. package/src/media/ImageGallery.tsx +224 -0
  73. package/src/media/MediaCard.tsx +100 -0
  74. package/src/media/imageGrid.ts +51 -0
  75. package/src/motion.ts +33 -0
  76. package/src/pagerSwipe.ts +102 -0
  77. package/src/palette.ts +8 -1
  78. package/src/primitives/button.tsx +4 -1
  79. package/src/primitives/keyboard-controller.tsx +106 -0
  80. package/src/primitives/keyboard-parts.tsx +104 -0
  81. package/src/primitives/overlay.tsx +133 -45
  82. package/src/primitives/pager-gesture.tsx +131 -0
  83. package/src/primitives/sheet-drag.tsx +305 -0
  84. package/src/primitives/sheet-gesture.tsx +181 -0
  85. package/src/primitives/swipe-row-gesture.tsx +290 -0
  86. package/src/primitives/toast-drag-gesture.tsx +92 -0
  87. package/src/primitives/web-click-guard.ts +50 -0
  88. package/src/relativeTime.ts +94 -0
  89. package/src/router/RouterStackPageHeader.tsx +2 -2
  90. package/src/settings/SettingsEmptyState.tsx +20 -10
  91. package/src/settings/SettingsField.tsx +11 -5
  92. package/src/settings/SettingsSection.tsx +8 -5
  93. package/src/settings/SettingsToggleRow.tsx +55 -22
  94. package/src/settings/relaxedSizeStyle.ts +11 -0
  95. package/src/sheetDrag.ts +67 -0
  96. package/src/swipeRow.ts +169 -0
  97. package/src/tabBarRoutes.ts +18 -0
  98. package/src/tabIndicator.ts +71 -0
  99. package/src/tailwind-preset.cjs +13 -6
  100. package/src/tailwind-preset.d.cts +5 -0
  101. package/src/theme.tsx +6 -2
  102. package/src/toastDrag.ts +58 -0
  103. package/src/typography.ts +20 -3
  104. package/src/useScheme.ts +10 -1
  105. package/src/video/InlineVideo.tsx +91 -0
  106. package/src/webview/HtmlView.tsx +91 -0
  107. package/src/webview/HtmlView.web.tsx +21 -0
  108. package/src/webview/htmlView.ts +45 -0
@@ -1,13 +1,13 @@
1
1
  import { useState, type ReactNode } from "react";
2
2
  import { Pressable, ScrollView, Text, View } from "react-native";
3
3
  import { IconArrowLeft, IconArrowRight, IconCheck, IconPencil } from "@tabler/icons-react-native";
4
- import { Badge } from "../Badge";
5
4
  import { Button } from "../Button";
6
5
  import { Input } from "../Input";
7
- import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
6
+ import { buttonColors, sizeStyle, textStyle, trackingStyle, typeStyle } from "../controlStyles";
8
7
  import { palette } from "../palette";
9
8
  import { useLargerText } from "../theme";
10
9
  import { tokens } from "../tokens";
10
+ import { useReduceMotion } from "../useReduceMotion";
11
11
  import { useScheme } from "../useScheme";
12
12
  import { chatTextSize } from "./chat-text";
13
13
 
@@ -35,6 +35,11 @@ export type ChoiceQuestionProps = {
35
35
  otherPlaceholder?: string;
36
36
  /** Inside the web dock card (`rounded-surface bg-card p-3`, scrolling past 60% of the screen). Default `true`. */
37
37
  framed?: boolean;
38
+ /**
39
+ * Back, Next and Submit label weight. Default `medium`, the `Button` label
40
+ * (web `font-medium`). `semibold` for apps whose web buttons are semibold, as Eva.
41
+ */
42
+ buttonWeight?: "medium" | "semibold";
38
43
  };
39
44
 
40
45
  const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
@@ -72,10 +77,24 @@ export function ChoiceQuestion({
72
77
  otherLabel = "Other...",
73
78
  otherPlaceholder = "Type your answer...",
74
79
  framed = true,
80
+ buttonWeight = "medium",
75
81
  }: ChoiceQuestionProps) {
76
82
  const scheme = useScheme();
77
83
  const largerText = useLargerText();
78
84
  const icon = largerText ? 18 : 15;
85
+ const buttonText = largerText ? "text-xl" : "text-sm";
86
+ // A string child draws the `Button` medium label; a semibold one is drawn here, in the variant's label colour.
87
+ const buttonLabel = (text: string, variant: "primary" | "ghost") =>
88
+ buttonWeight === "medium" ? (
89
+ text
90
+ ) : (
91
+ <Text
92
+ className={`${buttonText} font-instrument-sans-semibold`}
93
+ style={[sizeStyle(buttonText), typeStyle("semibold"), { color: buttonColors(variant, scheme)?.color }]}
94
+ >
95
+ {text}
96
+ </Text>
97
+ );
79
98
  const [step, setStep] = useState(0);
80
99
  const [picked, setPicked] = useState<Record<number, string[]>>({});
81
100
  const [other, setOther] = useState<Record<number, string>>({});
@@ -109,18 +128,13 @@ export function ChoiceQuestion({
109
128
  <View className="flex-row items-center justify-between" style={{ gap: 8 }}>
110
129
  <Text
111
130
  className="flex-1 font-instrument-sans-semibold"
112
- // Web `text-[15px] leading-snug`; larger text steps up to 18.
113
- style={[{ fontSize: largerText ? 18 : 15, lineHeight: largerText ? 24 : 20 }, textStyle("foreground", scheme), typeStyle("semibold")]}
131
+ // Web `text-[15px] leading-snug` (15 / 20.625); larger text steps up to 18.
132
+ style={[{ fontSize: largerText ? 18 : 15, lineHeight: largerText ? 24 : 15 * 1.375 }, textStyle("foreground", scheme), typeStyle("semibold")]}
114
133
  >
115
134
  {question.question}
116
135
  </Text>
117
136
  <View className="flex-row items-center" style={{ gap: 6 }}>
118
- {/* Web `text-3xs`: a compact badge beside the question. */}
119
- {question.header ? (
120
- <Badge variant="secondary" className="px-2 py-0.5" textClassName={largerText ? "text-sm" : "text-xs"}>
121
- {question.header}
122
- </Badge>
123
- ) : null}
137
+ {question.header ? <TopicBadge>{question.header}</TopicBadge> : null}
124
138
  {total > 1 ? (
125
139
  <Text className="font-instrument-sans-medium" style={[sizeStyle(chatTextSize("caption", largerText)), textStyle("muted", scheme), typeStyle("medium")]}>
126
140
  {`${step + 1}/${total}`}
@@ -135,6 +149,7 @@ export function ChoiceQuestion({
135
149
  key={`${option.label}-${index}`}
136
150
  selected={!otherOn[step] && selected.includes(option.label)}
137
151
  disabled={loading}
152
+ multiple={question.multiSelect}
138
153
  mark={LETTERS[index] ?? String(index + 1)}
139
154
  label={option.label}
140
155
  description={option.description}
@@ -144,6 +159,7 @@ export function ChoiceQuestion({
144
159
  <ChoiceRow
145
160
  selected={otherOn[step] ?? false}
146
161
  disabled={loading}
162
+ multiple={question.multiSelect}
147
163
  mark="other"
148
164
  label={otherLabel}
149
165
  onPress={() => {
@@ -159,6 +175,10 @@ export function ChoiceQuestion({
159
175
  placeholder={otherPlaceholder}
160
176
  editable={!loading}
161
177
  autoFocus
178
+ // Return is the Next or Submit button. Until there is an
179
+ // answer it keeps the keyboard up, as the button does nothing.
180
+ returnKeyType={isLast ? "send" : "next"}
181
+ submitBehavior={isLast && hasAnswer ? "blurAndSubmit" : "submit"}
162
182
  onSubmitEditing={next}
163
183
  />
164
184
  </View>
@@ -174,8 +194,9 @@ export function ChoiceQuestion({
174
194
  disabled={loading}
175
195
  onPress={() => setStep(step - 1)}
176
196
  startContent={<IconArrowLeft size={icon} strokeWidth={2.5} color={tokens.color.foreground[scheme]} />}
197
+ accessibilityLabel="Back"
177
198
  >
178
- Back
199
+ {buttonLabel("Back", "ghost")}
179
200
  </Button>
180
201
  </View>
181
202
  ) : null}
@@ -184,11 +205,12 @@ export function ChoiceQuestion({
184
205
  disabled={!hasAnswer}
185
206
  loading={loading}
186
207
  onPress={next}
208
+ accessibilityLabel={isLast ? "Submit" : "Next"}
187
209
  endContent={
188
210
  loading ? null : <IconArrowRight size={icon} strokeWidth={2.5} color={palette.primaryForeground[scheme]} />
189
211
  }
190
212
  >
191
- {isLast ? "Submit" : "Next"}
213
+ {buttonLabel(isLast ? "Submit" : "Next", "primary")}
192
214
  </Button>
193
215
  </View>
194
216
  </View>
@@ -205,14 +227,55 @@ export function ChoiceQuestion({
205
227
  );
206
228
  }
207
229
 
230
+ /**
231
+ * The question's topic, the web `Badge variant="secondary"` with Eva's
232
+ * `text-3xs font-semibold` (10 / 14, 600) in a `px-2.5 py-0.5` pill. Drawn here
233
+ * because `Badge` sets its own size and weight.
234
+ */
235
+ function TopicBadge({ children }: { children: string }) {
236
+ const scheme = useScheme();
237
+ const largerText = useLargerText();
238
+ const fontSize = largerText ? 14 : 10;
239
+ return (
240
+ <View
241
+ className="rounded-full px-2.5 py-0.5"
242
+ style={{ backgroundColor: palette.default[scheme], borderRadius: tokens.radius.pill }}
243
+ >
244
+ <Text
245
+ numberOfLines={1}
246
+ className="font-instrument-sans-semibold"
247
+ style={[
248
+ { fontSize, lineHeight: largerText ? 20 : 14, color: palette.defaultForeground[scheme] },
249
+ typeStyle("semibold"),
250
+ ]}
251
+ >
252
+ {children}
253
+ </Text>
254
+ </View>
255
+ );
256
+ }
257
+
258
+ /** The option card's `py-2 px-2.5`, which is also the slop of its press target. */
259
+ const CARD_PADDING = { top: 8, bottom: 8, left: 10, right: 10 };
260
+
261
+ /** A whole card is at least a 44 target: the press target inside is this much less its slop. */
262
+ const CARD_MIN_HEIGHT = 44;
263
+
264
+ /** The card while pressed: the kit 3% press scale, or a dim with Reduce Motion. */
265
+ export function choicePressStyle(pressed: boolean, reduceMotion: boolean) {
266
+ if (!pressed) return null;
267
+ return reduceMotion ? { opacity: 0.8 } : { transform: [{ scale: 0.97 }] };
268
+ }
269
+
208
270
  /**
209
271
  * One option row (web `Card` with `bg-secondary`, `py-2 px-2.5`, radius 16): a round 24 mark
210
272
  * (30 with larger text; letter, or a pencil for "Other"; a check on the primary fill when picked),
211
- * the label and the description. Picked: a 2-wide primary ring on the accent.
273
+ * the label and the description. Picked: a 1-wide primary ring outside, on the accent.
212
274
  */
213
275
  function ChoiceRow({
214
276
  selected,
215
277
  disabled,
278
+ multiple,
216
279
  mark,
217
280
  label,
218
281
  description,
@@ -221,6 +284,8 @@ function ChoiceRow({
221
284
  }: {
222
285
  selected: boolean;
223
286
  disabled: boolean;
287
+ /** A multi-select question: each row is a `checkbox`, not a `radio`. */
288
+ multiple: boolean;
224
289
  mark: string;
225
290
  label: string;
226
291
  description?: string;
@@ -229,31 +294,67 @@ function ChoiceRow({
229
294
  }) {
230
295
  const scheme = useScheme();
231
296
  const largerText = useLargerText();
297
+ const reduceMotion = useReduceMotion();
298
+ // The scale moves the whole card, which is not the Pressable (the "Other" field sits in it too).
299
+ const [pressed, setPressed] = useState(false);
232
300
  const muted = tokens.color.muted[scheme];
233
301
  const markSize = largerText ? 30 : 24;
302
+ const markText = largerText ? 14 : 11;
234
303
  const glyph = largerText ? 16 : 13;
304
+ const small = chatTextSize("small", largerText);
305
+ // Web: an option label is `text-sm leading-snug font-medium`; "Other..." is
306
+ // plain `text-sm`, medium only once picked.
307
+ const plain = mark === "other" && !selected;
308
+ // The web label is an inline `leading-snug` span in a 16 / 24 block, so each
309
+ // line box is the block's 24, not 19.25: rows are 24 a line, as on the web.
310
+ const labelLine = largerText ? sizeStyle(small, "snug") : { ...sizeStyle(small, "snug"), lineHeight: 24 };
235
311
  return (
236
312
  <View
237
- style={{
238
- // Web `Card` (rounded-surface, 16) with `py-2 px-2.5` inside a 1 border + 1 ring.
239
- borderRadius: tokens.radius.lg,
240
- borderWidth: 2,
241
- borderColor: selected ? palette.primary[scheme] : "transparent",
242
- backgroundColor: selected ? tokens.color.background[scheme] : palette.default[scheme],
243
- opacity: disabled ? 0.5 : 1,
244
- paddingVertical: 8,
245
- paddingHorizontal: 10,
246
- }}
313
+ style={[
314
+ {
315
+ // Web `Card` (rounded-surface, 16) with `py-2 px-2.5` and no border width.
316
+ borderRadius: tokens.radius.lg,
317
+ backgroundColor: selected ? tokens.color.background[scheme] : palette.default[scheme],
318
+ opacity: disabled ? 0.5 : 1,
319
+ paddingVertical: CARD_PADDING.top,
320
+ paddingHorizontal: CARD_PADDING.left,
321
+ },
322
+ choicePressStyle(pressed && !disabled, reduceMotion),
323
+ ]}
247
324
  >
325
+ {selected ? (
326
+ // Web `ring-1 ring-primary`: a 1 line just outside the card, so picking a row moves nothing.
327
+ <View
328
+ pointerEvents="none"
329
+ style={{
330
+ position: "absolute",
331
+ top: -1,
332
+ left: -1,
333
+ right: -1,
334
+ bottom: -1,
335
+ borderRadius: tokens.radius.lg + 1,
336
+ borderWidth: 1,
337
+ borderColor: palette.primary[scheme],
338
+ }}
339
+ />
340
+ ) : null}
248
341
  <Pressable
249
- accessibilityRole="button"
342
+ accessibilityRole={multiple ? "checkbox" : "radio"}
250
343
  accessibilityLabel={label}
251
- accessibilityState={{ selected, disabled }}
344
+ accessibilityHint={description || undefined}
345
+ accessibilityState={{ checked: selected, disabled }}
252
346
  disabled={disabled}
347
+ // The card's own padding: a tap anywhere on the card picks it, and stops at its edge.
348
+ hitSlop={CARD_PADDING}
253
349
  onPress={onPress}
254
- hitSlop={4}
350
+ onPressIn={() => setPressed(true)}
351
+ onPressOut={() => setPressed(false)}
255
352
  className="flex-row"
256
- style={{ gap: 12, alignItems: description ? "flex-start" : "center" }}
353
+ style={{
354
+ gap: 12,
355
+ alignItems: description ? "flex-start" : "center",
356
+ minHeight: CARD_MIN_HEIGHT - CARD_PADDING.top - CARD_PADDING.bottom,
357
+ }}
257
358
  >
258
359
  <View
259
360
  style={{
@@ -272,20 +373,38 @@ function ChoiceRow({
272
373
  ) : mark === "other" ? (
273
374
  <IconPencil size={glyph} color={muted} />
274
375
  ) : (
275
- <Text className="font-instrument-sans-semibold" style={[typeStyle("semibold"), { fontSize: largerText ? 14 : 11, color: muted, letterSpacing: 0.4 }]}>
376
+ // Web `text-2xs font-bold tracking-wide`, at semibold: native-ui draws no bold face.
377
+ <Text
378
+ className="font-instrument-sans-semibold"
379
+ style={[
380
+ { fontSize: markText, lineHeight: largerText ? 20 : 16, color: muted },
381
+ trackingStyle(markText, "wide"),
382
+ typeStyle("semibold"),
383
+ ]}
384
+ >
276
385
  {mark}
277
386
  </Text>
278
387
  )}
279
388
  </View>
280
389
  <View className="min-w-0 flex-1">
281
- <Text
282
- className="font-instrument-sans-medium"
283
- style={[sizeStyle(chatTextSize("small", largerText), "snug"), typeStyle("medium"), { color: selected ? palette.primary[scheme] : muted }]}
284
- >
285
- {label}
286
- </Text>
390
+ {plain ? (
391
+ <Text className="font-instrument-sans" style={[sizeStyle(small), { color: muted }]}>
392
+ {label}
393
+ </Text>
394
+ ) : (
395
+ <Text
396
+ className="font-instrument-sans-medium"
397
+ style={[labelLine, typeStyle("medium"), { color: selected ? palette.primary[scheme] : muted }]}
398
+ >
399
+ {label}
400
+ </Text>
401
+ )}
287
402
  {description ? (
288
- <Text className="font-instrument-sans" style={[sizeStyle(chatTextSize("caption", largerText)), { color: muted, marginTop: 2 }]}>
403
+ // Web `text-xs mt-0.5 leading-relaxed` (12 / 19.5).
404
+ <Text
405
+ className="font-instrument-sans"
406
+ style={[sizeStyle(chatTextSize("caption", largerText), "relaxed"), { color: muted, marginTop: 2 }]}
407
+ >
289
408
  {description}
290
409
  </Text>
291
410
  ) : null}
@@ -1,14 +1,34 @@
1
1
  import { Fragment, useEffect, useRef, useState } from "react";
2
2
  import { ScrollView, Text, View } from "react-native";
3
- import { IconCheck, IconCopy } from "@tabler/icons-react-native";
3
+ import { IconCheck, IconCopy, IconTextWrap } from "@tabler/icons-react-native";
4
4
  import { cn } from "../cn";
5
5
  import { sizeStyle, textStyle } from "../controlStyles";
6
- import { GITHUB_CODE_COLORS, codeTokenColor, loadPrismReactRenderer } from "../highlight";
6
+ import {
7
+ GITHUB_CODE_COLORS,
8
+ SCRIPT_LANGUAGES,
9
+ SHELL_LANGUAGES,
10
+ codeTokenColor,
11
+ loadPrismReactRenderer,
12
+ scriptTokens,
13
+ shellTokens,
14
+ type CodeToken,
15
+ } from "../highlight";
7
16
  import { useLargerText } from "../theme";
8
17
  import { tokens } from "../tokens";
9
18
  import { useScheme } from "../useScheme";
10
19
  import { MONO_FONT, chatTextSize } from "./chat-text";
11
- import { ChatIconButton } from "./icon-button";
20
+ import { ChatIconButton, type ChatIconButtonProps, type ChatIconButtonSize } from "./icon-button";
21
+
22
+ /**
23
+ * `variant="markdown"`, the web `.markdown-code` (`MarkdownCodeBlock`): the header is `0.25rem 0.3rem 0 0.9rem` around a 28
24
+ * line (32 high), the code `0.25rem 0.9rem 0.8rem` at 13 with line-height
25
+ * 1.55, and the header actions are `icon-xs` (28) buttons.
26
+ */
27
+ export const CODE_BLOCK_METRICS = {
28
+ header: { minHeight: 32, paddingTop: 4, paddingLeft: 14, paddingRight: 5 },
29
+ code: { paddingTop: 4, paddingHorizontal: 14, paddingBottom: 13 },
30
+ text: { fontSize: 13, lineHeight: 13 * 1.55 },
31
+ } as const;
12
32
 
13
33
  export type CodeBlockCopyButtonProps = {
14
34
  code: string;
@@ -23,10 +43,14 @@ export type CodeBlockCopyButtonProps = {
23
43
  timeout?: number;
24
44
  /** Default "Copy code"; "Copied" while the check shows. */
25
45
  label?: string;
46
+ /** Default `sm` (the web `CodeBlock` copy button); the markdown code block uses `xs`. */
47
+ size?: ChatIconButtonSize;
48
+ /** Default `"square"`. See `ChatIconButton`. */
49
+ slop?: ChatIconButtonProps["slop"];
26
50
  };
27
51
 
28
52
  /** Ghost copy button: the copy icon, then a check for `timeout` after a copy. */
29
- export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Copy code" }: CodeBlockCopyButtonProps) {
53
+ export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Copy code", size, slop }: CodeBlockCopyButtonProps) {
30
54
  const [copied, setCopied] = useState(false);
31
55
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
32
56
 
@@ -48,6 +72,8 @@ export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Cop
48
72
  <ChatIconButton
49
73
  icon={copied ? IconCheck : IconCopy}
50
74
  label={copied ? "Copied" : label}
75
+ size={size}
76
+ slop={slop}
51
77
  onPress={() => void copy()}
52
78
  />
53
79
  );
@@ -68,80 +94,194 @@ export type CodeBlockProps = {
68
94
  highlight?: boolean;
69
95
  /** With it, the header has a copy button. See `CodeBlockCopyButton`. */
70
96
  onCopy?: (code: string) => void | Promise<void>;
97
+ /** Default `default`, the web `CodeBlock`. `markdown` is the web `MarkdownCodeBlock` (see `CodeBlock`). */
98
+ variant?: "default" | "markdown";
71
99
  className?: string;
72
100
  };
73
101
 
74
102
  /**
75
- * Code on the flat panel (web `flatPanelSubtle`: `bg-surface-secondary`, no
76
- * border or shadow) at radius 16. A header with the language and a copy
77
- * button when either is given. Long lines scroll sideways; the code is
78
- * selectable. With `prism-react-renderer` installed and a `language`, tokens
103
+ * Code on the flat panel at radius 16, selectable, long lines scrolling
104
+ * sideways. With `prism-react-renderer` installed and a `language`, tokens
79
105
  * take the GitHub colours of the scheme.
106
+ *
107
+ * `default` is the web `CodeBlock` (`ai-elements/code-block.tsx`): `text-sm`
108
+ * code in `p-4`, and a `px-3` header with the language and a copy button when
109
+ * either is given. `markdown` is the web `MarkdownCodeBlock`, which `Markdown`
110
+ * draws for fenced code: 13 / 20.15 code, a 32 header with `icon-xs` actions,
111
+ * a wrap toggle ("Wrap long lines", foreground while on) and then copy.
80
112
  */
81
- export function CodeBlock({ code, language, highlight = true, onCopy, className }: CodeBlockProps) {
82
- const scheme = useScheme();
83
- const largerText = useLargerText();
84
- const size = chatTextSize("small", largerText);
85
- const caption = chatTextSize("caption", largerText);
113
+ /**
114
+ * Android fades the code at a side with more to scroll (there is no scroll
115
+ * bar), so a cut line reads as "scroll sideways". iOS ignores it.
116
+ */
117
+ const CODE_FADING_EDGE = 24;
118
+
119
+ export function CodeBlock({ code, language, highlight = true, onCopy, variant = "default", className }: CodeBlockProps) {
120
+ const parts = useCodeParts(code, language, highlight, variant === "markdown");
121
+ if (variant === "markdown") {
122
+ return <MarkdownCodeBlock code={code} language={language} onCopy={onCopy} className={className} parts={parts} />;
123
+ }
86
124
  const header = Boolean(language || onCopy);
87
125
  return (
88
- <View
89
- className={cn("w-full overflow-hidden", className)}
90
- style={{ borderRadius: tokens.radius.lg, backgroundColor: tokens.color.surfaceSecondary[scheme] }}
91
- >
126
+ <View className={cn("w-full overflow-hidden", className)} style={parts.panel}>
92
127
  {header ? (
93
128
  <View className="min-h-11 flex-row items-center justify-between pl-3 pr-1.5">
94
- <Text className={caption} style={[sizeStyle(caption), textStyle("muted", scheme), { fontFamily: MONO_FONT }]}>
95
- {language ?? ""}
96
- </Text>
129
+ {parts.label}
97
130
  {onCopy ? <CodeBlockCopyButton code={code} onCopy={onCopy} /> : null}
98
131
  </View>
99
132
  ) : null}
100
133
  <ScrollView
101
134
  horizontal
102
135
  showsHorizontalScrollIndicator={false}
136
+ fadingEdgeLength={CODE_FADING_EDGE}
103
137
  contentContainerClassName={header ? "px-4 pb-4" : "p-4"}
104
138
  >
105
- <Text
106
- selectable
107
- className={size}
108
- style={[sizeStyle(size), textStyle("foreground", scheme), { fontFamily: MONO_FONT }]}
109
- >
110
- {highlight && language ? <HighlightedCode code={code} language={language} scheme={scheme} /> : code}
111
- </Text>
139
+ {parts.text}
112
140
  </ScrollView>
113
141
  </View>
114
142
  );
115
143
  }
116
144
 
145
+ /** The web `MarkdownCodeBlock`: `CodeBlock` with `variant="markdown"`. A component of its own for the wrap state. */
146
+ function MarkdownCodeBlock({
147
+ code,
148
+ language,
149
+ onCopy,
150
+ className,
151
+ parts,
152
+ }: Pick<CodeBlockProps, "code" | "language" | "onCopy" | "className"> & { parts: ReturnType<typeof useCodeParts> }) {
153
+ const scheme = useScheme();
154
+ const largerText = useLargerText();
155
+ const [wrap, setWrap] = useState(false);
156
+ const header = Boolean(language || onCopy);
157
+ const { header: headerBox, code: codeBox } = CODE_BLOCK_METRICS;
158
+ const padding = header ? codeBox : { ...codeBox, paddingTop: codeBox.paddingBottom };
159
+ return (
160
+ <View className={cn("w-full overflow-hidden", className)} style={parts.panel}>
161
+ {header ? (
162
+ <View
163
+ className={cn("flex-row items-center justify-between gap-2", largerText && "min-h-11")}
164
+ style={largerText ? { paddingLeft: headerBox.paddingLeft, paddingRight: headerBox.paddingRight } : headerBox}
165
+ >
166
+ {parts.label}
167
+ {/* Web `.markdown-code-actions`: `gap-0.5`. The two sit 2 apart, so
168
+ each slops only above and below and on its outer side. */}
169
+ <View className="flex-row items-center gap-0.5">
170
+ <ChatIconButton
171
+ icon={IconTextWrap}
172
+ label={wrap ? "Scroll long lines" : "Wrap long lines"}
173
+ size="xs"
174
+ iconColor={wrap ? tokens.color.foreground[scheme] : tokens.color.muted[scheme]}
175
+ accessibilityState={{ selected: wrap }}
176
+ slop={{ left: 8, right: 0 }}
177
+ onPress={() => setWrap(!wrap)}
178
+ />
179
+ {onCopy ? <CodeBlockCopyButton code={code} onCopy={onCopy} size="xs" slop={{ left: 0, right: 5 }} /> : null}
180
+ </View>
181
+ </View>
182
+ ) : null}
183
+ {wrap ? (
184
+ <View style={padding}>{parts.text}</View>
185
+ ) : (
186
+ <ScrollView
187
+ horizontal
188
+ showsHorizontalScrollIndicator={false}
189
+ fadingEdgeLength={CODE_FADING_EDGE}
190
+ contentContainerStyle={padding}
191
+ >
192
+ {parts.text}
193
+ </ScrollView>
194
+ )}
195
+ </View>
196
+ );
197
+ }
198
+
199
+ /** The panel style, the language label and the code text both code blocks draw. */
200
+ function useCodeParts(code: string, language: string | undefined, highlight: boolean, markdown: boolean) {
201
+ const scheme = useScheme();
202
+ const largerText = useLargerText();
203
+ const size = chatTextSize("small", largerText);
204
+ const caption = chatTextSize("caption", largerText);
205
+ const text = (
206
+ <Text
207
+ selectable
208
+ className={size}
209
+ style={[
210
+ markdown && !largerText ? CODE_BLOCK_METRICS.text : sizeStyle(size),
211
+ textStyle("foreground", scheme),
212
+ { fontFamily: MONO_FONT },
213
+ ]}
214
+ >
215
+ {highlight && language ? <HighlightedCode code={code} language={language} scheme={scheme} /> : code}
216
+ </Text>
217
+ );
218
+ return {
219
+ text,
220
+ label: (
221
+ <Text className={caption} style={[sizeStyle(caption), textStyle("muted", scheme), { fontFamily: MONO_FONT }]}>
222
+ {language ?? ""}
223
+ </Text>
224
+ ),
225
+ panel: { borderRadius: tokens.radius.lg, backgroundColor: tokens.color.surfaceSecondary[scheme] },
226
+ };
227
+ }
228
+
117
229
  /**
118
- * Prism tokens as nested `Text` spans, one colour each, lines joined by
119
- * newlines. They inherit the size and font of the code `Text` around them.
120
- * Without `prism-react-renderer` it is the plain code.
230
+ * Coloured runs as nested `Text` spans, lines joined by newlines. They
231
+ * inherit the size and font of the code `Text` around them.
232
+ */
233
+ function CodeRuns({ lines, scheme }: { lines: CodeToken[][]; scheme: "light" | "dark" }) {
234
+ return (
235
+ <>
236
+ {lines.map((line, lineIndex) => (
237
+ <Fragment key={lineIndex}>
238
+ {lineIndex > 0 ? "\n" : null}
239
+ {line.map((token, tokenIndex) => (
240
+ <Text key={tokenIndex} style={{ color: GITHUB_CODE_COLORS[scheme][token.role] }}>
241
+ {token.content}
242
+ </Text>
243
+ ))}
244
+ </Fragment>
245
+ ))}
246
+ </>
247
+ );
248
+ }
249
+
250
+ /**
251
+ * The code in the web's Shiki colours: shell blocks through `shellTokens`
252
+ * (no peer needed), other languages through Prism, the JavaScript family
253
+ * recoloured where Prism and Shiki disagree (`scriptTokens`). Without
254
+ * `prism-react-renderer` a non-shell block is the plain code.
121
255
  */
122
256
  function HighlightedCode({ code, language, scheme }: { code: string; language: string; scheme: "light" | "dark" }) {
257
+ const lang = language.toLowerCase();
258
+ const source = code.replace(/\n$/, "");
259
+ if (SHELL_LANGUAGES.has(lang)) return <CodeRuns lines={shellTokens(source)} scheme={scheme} />;
123
260
  const Highlight = loadPrismReactRenderer()?.Highlight;
124
261
  if (!Highlight) return code;
125
262
  return (
126
- <Highlight
127
- code={code.replace(/\n$/, "")}
128
- language={language.toLowerCase()}
129
- theme={{ plain: { color: GITHUB_CODE_COLORS[scheme].plain }, styles: [] }}
130
- >
131
- {({ tokens }) => (
132
- <>
133
- {tokens.map((line, lineIndex) => (
134
- <Fragment key={lineIndex}>
135
- {lineIndex > 0 ? "\n" : null}
136
- {line.map((token, tokenIndex) => (
137
- <Text key={tokenIndex} style={{ color: codeTokenColor(token.types, scheme) }}>
138
- {token.content}
139
- </Text>
140
- ))}
141
- </Fragment>
142
- ))}
143
- </>
144
- )}
263
+ <Highlight code={source} language={lang} theme={{ plain: { color: GITHUB_CODE_COLORS[scheme].plain }, styles: [] }}>
264
+ {({ tokens }) =>
265
+ SCRIPT_LANGUAGES.has(lang) ? (
266
+ <CodeRuns lines={scriptTokens(tokens)} scheme={scheme} />
267
+ ) : (
268
+ <>
269
+ {tokens.map((line, lineIndex) => (
270
+ <Fragment key={lineIndex}>
271
+ {lineIndex > 0 ? "\n" : null}
272
+ {/* An empty line is one `empty` token whose content is "\n": the join above already breaks it. */}
273
+ {line.map((token, tokenIndex) =>
274
+ token.empty ? null : (
275
+ <Text key={tokenIndex} style={{ color: codeTokenColor(token.types, scheme) }}>
276
+ {token.content}
277
+ </Text>
278
+ ),
279
+ )}
280
+ </Fragment>
281
+ ))}
282
+ </>
283
+ )
284
+ }
145
285
  </Highlight>
146
286
  );
147
287
  }