@xpufx/paseo-mcp-tools 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -0
  3. package/client/__snapshots__/attribution.test.tsx.snap +170 -0
  4. package/client/commands.ts +113 -0
  5. package/client/mcp-query.tsx +39 -0
  6. package/client/pill.tsx +886 -0
  7. package/client/timeline.tsx +136 -0
  8. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  9. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  10. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  11. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  12. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  13. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  14. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  15. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  16. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  17. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  18. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  19. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  20. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  21. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  22. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  23. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  24. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  25. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  26. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  27. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  28. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  29. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  30. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  31. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  32. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  33. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  34. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  35. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  36. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  37. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  38. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  39. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  40. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  41. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  42. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  43. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  44. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  45. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  46. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  47. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  48. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  49. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  50. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  51. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  52. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  53. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  54. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  55. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  56. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  57. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  58. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  59. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  60. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  61. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  62. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  63. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  64. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  65. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  66. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  67. package/docs/TEST_METHODOLOGY.md +29 -0
  68. package/package.json +41 -0
  69. package/paseo-plugin.json +4 -0
  70. package/scripts/version.mjs +4 -0
  71. package/server/discovery/extract.ts +277 -0
  72. package/server/discovery/types.ts +23 -0
  73. package/server/health/health.ts +274 -0
  74. package/server/inject.ts +32 -0
  75. package/server/mcp.ts +365 -0
  76. package/server/providers/README.md +51 -0
  77. package/server/providers/antigravity.ts +22 -0
  78. package/server/providers/catalog.ts +6 -0
  79. package/server/providers/claude.ts +21 -0
  80. package/server/providers/codex.ts +85 -0
  81. package/server/providers/family.ts +24 -0
  82. package/server/providers/index.ts +11 -0
  83. package/server/providers/opencode.ts +95 -0
  84. package/server/providers/paseo.ts +112 -0
  85. package/server/providers/pi.ts +39 -0
  86. package/server/settings.ts +19 -0
  87. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  88. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  89. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  90. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  91. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  92. package/server/vendor/paseo-plugin-helper/mcp/client.ts +349 -0
  93. package/server/vendor/paseo-plugin-helper/mcp/http-client.ts +399 -0
  94. package/server/vendor/paseo-plugin-helper/mcp/index.ts +5 -0
  95. package/server/vendor/paseo-plugin-helper/mcp/process-killer.ts +65 -0
  96. package/server/vendor/paseo-plugin-helper/mcp/ring-buffer.ts +29 -0
  97. package/server/vendor/paseo-plugin-helper/mcp/types.ts +41 -0
  98. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  99. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  100. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  101. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  102. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  103. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  104. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  105. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  106. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  107. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  108. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  109. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  110. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  111. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  112. package/shared/mcp.ts +207 -0
  113. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  114. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  115. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  116. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  117. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  118. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  119. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  120. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  121. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  122. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  123. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  124. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  125. package/shared/version.ts +2 -0
@@ -0,0 +1,215 @@
1
+ import React, { useState } from "react";
2
+ import {
3
+ Pressable,
4
+ ScrollView as FallbackScrollView,
5
+ StyleSheet,
6
+ Text,
7
+ View,
8
+ type StyleProp,
9
+ type ViewStyle,
10
+ } from "react-native";
11
+ import { getClientHost } from "../host";
12
+ import { usePluginTheme } from "../theme/provider";
13
+ import { spacing } from "../theme/tokens";
14
+ import type { BadgeSize } from "./Badge";
15
+
16
+ export interface SelectOption {
17
+ label: string;
18
+ value: string;
19
+ }
20
+
21
+ export interface SelectProps {
22
+ value: string;
23
+ options: SelectOption[];
24
+ onValueChange: (value: string) => void;
25
+ label?: string;
26
+ /** Compact/pill scale shared with {@link Badge}: `"md"` (default) or `"sm"`. */
27
+ size?: BadgeSize;
28
+ placeholder?: string;
29
+ disabled?: boolean;
30
+ style?: StyleProp<ViewStyle>;
31
+ }
32
+
33
+ const OPTION_LIST_MAX_HEIGHT = 216;
34
+
35
+ /**
36
+ * Compact single-choice picker sized to sit inside a {@link FormRow}. The
37
+ * closed trigger stays one line tall; opening reveals a bounded, scrollable
38
+ * option list, so a long list degrades to scrolling rather than overflow.
39
+ */
40
+ export function Select({
41
+ value,
42
+ options,
43
+ onValueChange,
44
+ label,
45
+ size = "md",
46
+ placeholder = "Select…",
47
+ disabled = false,
48
+ style,
49
+ }: SelectProps) {
50
+ const { Icon } = getClientHost();
51
+ // The dropdown option list is a bounded box nested inside the host sheet,
52
+ // so it scrolls with a plain React Native ScrollView — never the host
53
+ // sheet-gesture scroller (#219).
54
+ const { colors, resolveRadius, typography, isCompact, touchTargetMin, alpha } =
55
+ usePluginTheme();
56
+ const [open, setOpen] = useState(false);
57
+
58
+ const caption = typography?.caption ?? {
59
+ fontSize: 11,
60
+ lineHeight: 15,
61
+ fontWeight: "400" as const,
62
+ };
63
+ const fontSize = size === "sm" ? 10 : caption.fontSize;
64
+ const lineHeight = size === "sm" ? 12 : caption.lineHeight;
65
+ const paddingVertical = size === "sm" ? 3 : 6;
66
+ const paddingHorizontal = size === "sm" ? 8 : 10;
67
+ const iconSize = size === "sm" ? 12 : 14;
68
+ const radius = resolveRadius("md");
69
+
70
+ // A free-text value (advanced entry) may not be one of the options; surface
71
+ // it on the trigger rather than masking it behind the placeholder.
72
+ const selected = options.find((option) => option.value === value);
73
+ const display = selected?.label ?? (value ? value : placeholder);
74
+ const canOpen = !disabled && options.length > 0;
75
+ const isOpen = open && canOpen;
76
+
77
+ const triggerMinHeight = size === "sm" ? 28 : Math.max(34, touchTargetMin);
78
+
79
+ return (
80
+ <View style={[styles.container, style]}>
81
+ <Pressable
82
+ accessibilityRole="button"
83
+ accessibilityLabel={label ? `${label}: ${display}` : display}
84
+ accessibilityState={{ expanded: isOpen, disabled }}
85
+ disabled={!canOpen}
86
+ onPress={() => setOpen((prev) => !prev)}
87
+ style={({ pressed }) => [
88
+ styles.trigger,
89
+ {
90
+ minHeight: triggerMinHeight,
91
+ borderRadius: radius,
92
+ borderColor: isOpen ? colors.accent : colors.border,
93
+ backgroundColor:
94
+ disabled || !canOpen
95
+ ? alpha(colors.surface1, 0.5)
96
+ : pressed
97
+ ? colors.surface2
98
+ : colors.surface0,
99
+ paddingVertical,
100
+ paddingHorizontal,
101
+ },
102
+ ]}
103
+ >
104
+ <Text
105
+ numberOfLines={1}
106
+ style={[
107
+ styles.triggerText,
108
+ {
109
+ color: disabled || !canOpen ? colors.foregroundMuted : colors.foreground,
110
+ fontSize,
111
+ lineHeight,
112
+ fontWeight: selected ? "600" : "500",
113
+ },
114
+ ]}
115
+ >
116
+ {display}
117
+ </Text>
118
+ <Icon
119
+ name={isOpen ? "ChevronUp" : "ChevronDown"}
120
+ size={iconSize}
121
+ color={colors.foregroundMuted}
122
+ />
123
+ </Pressable>
124
+
125
+ {isOpen ? (
126
+ <View
127
+ style={[
128
+ styles.optionList,
129
+ {
130
+ borderRadius: radius,
131
+ borderColor: colors.border,
132
+ backgroundColor: colors.surface0,
133
+ marginTop: spacing.xs,
134
+ },
135
+ ]}
136
+ >
137
+ <FallbackScrollView
138
+ nestedScrollEnabled
139
+ keyboardShouldPersistTaps="handled"
140
+ style={{ maxHeight: OPTION_LIST_MAX_HEIGHT }}
141
+ >
142
+ {options.map((option) => {
143
+ const isSelected = option.value === value;
144
+ return (
145
+ <Pressable
146
+ key={option.value}
147
+ accessibilityRole="button"
148
+ accessibilityLabel={option.label}
149
+ accessibilityState={{ selected: isSelected }}
150
+ onPress={() => {
151
+ onValueChange(option.value);
152
+ setOpen(false);
153
+ }}
154
+ style={({ pressed }) => [
155
+ styles.option,
156
+ {
157
+ paddingVertical,
158
+ paddingHorizontal,
159
+ backgroundColor: isSelected
160
+ ? colors.surface2
161
+ : pressed
162
+ ? alpha(colors.surface2, 0.5)
163
+ : "transparent",
164
+ },
165
+ ]}
166
+ >
167
+ <Text
168
+ numberOfLines={1}
169
+ style={[
170
+ styles.optionText,
171
+ {
172
+ color: isSelected ? colors.foreground : colors.foregroundMuted,
173
+ fontSize: isCompact ? fontSize : fontSize + 1,
174
+ lineHeight,
175
+ fontWeight: isSelected ? "600" : "400",
176
+ },
177
+ ]}
178
+ >
179
+ {option.label}
180
+ </Text>
181
+ </Pressable>
182
+ );
183
+ })}
184
+ </FallbackScrollView>
185
+ </View>
186
+ ) : null}
187
+ </View>
188
+ );
189
+ }
190
+
191
+ const styles = StyleSheet.create({
192
+ container: {
193
+ width: "100%",
194
+ },
195
+ trigger: {
196
+ flexDirection: "row",
197
+ alignItems: "center",
198
+ justifyContent: "space-between",
199
+ gap: spacing.sm,
200
+ borderWidth: 1,
201
+ },
202
+ triggerText: {
203
+ flexShrink: 1,
204
+ minWidth: 0,
205
+ },
206
+ optionList: {
207
+ borderWidth: 1,
208
+ overflow: "hidden",
209
+ },
210
+ option: {},
211
+ optionText: {
212
+ flexShrink: 1,
213
+ minWidth: 0,
214
+ },
215
+ });
@@ -0,0 +1,80 @@
1
+ import React, { useEffect, useRef } from "react";
2
+ import { Animated, StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
3
+ import { usePluginTheme } from "../theme/provider";
4
+ import type { StatusVariant } from "../../../../shared/vendor/paseo-plugin-helper/types";
5
+
6
+ export interface StatusDotProps {
7
+ variant?: StatusVariant;
8
+ size?: "sm" | "md" | "lg";
9
+ pulse?: boolean;
10
+ style?: StyleProp<ViewStyle>;
11
+ }
12
+
13
+ export function StatusDot({ variant = "neutral", size = "md", pulse = false, style }: StatusDotProps) {
14
+ const { getStatusColor, alpha } = usePluginTheme();
15
+ const color = getStatusColor(variant);
16
+ const pulseAnim = useRef(new Animated.Value(1)).current;
17
+
18
+ useEffect(() => {
19
+ if (!pulse) return;
20
+
21
+ const loop = Animated.loop(
22
+ Animated.sequence([
23
+ Animated.timing(pulseAnim, {
24
+ toValue: 0.35,
25
+ duration: 900,
26
+ useNativeDriver: true,
27
+ }),
28
+ Animated.timing(pulseAnim, {
29
+ toValue: 1,
30
+ duration: 900,
31
+ useNativeDriver: true,
32
+ }),
33
+ ]),
34
+ );
35
+ loop.start();
36
+
37
+ return () => loop.stop();
38
+ }, [pulse, pulseAnim]);
39
+
40
+ const dimension = size === "sm" ? 6 : size === "lg" ? 10 : 8;
41
+
42
+ return (
43
+ <View
44
+ style={[
45
+ styles.container,
46
+ {
47
+ width: dimension,
48
+ height: dimension,
49
+ },
50
+ style,
51
+ ]}
52
+ >
53
+ <Animated.View
54
+ style={[
55
+ styles.dot,
56
+ {
57
+ width: dimension,
58
+ height: dimension,
59
+ borderRadius: dimension / 2,
60
+ backgroundColor: color,
61
+ shadowColor: color,
62
+ opacity: pulseAnim,
63
+ },
64
+ ]}
65
+ />
66
+ </View>
67
+ );
68
+ }
69
+
70
+ const styles = StyleSheet.create({
71
+ container: {
72
+ alignItems: "center",
73
+ justifyContent: "center",
74
+ },
75
+ dot: {
76
+ shadowOffset: { width: 0, height: 0 },
77
+ shadowOpacity: 0.4,
78
+ shadowRadius: 3,
79
+ },
80
+ });
@@ -0,0 +1,319 @@
1
+ import React, { useEffect, useRef, useState, type ComponentType, type Ref } from "react";
2
+ import {
3
+ Platform,
4
+ Pressable,
5
+ ScrollView as FallbackScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ View,
9
+ type LayoutChangeEvent,
10
+ type ScrollView as ScrollViewInstance,
11
+ type StyleProp,
12
+ type ViewStyle,
13
+ } from "react-native";
14
+ import { getClientHost } from "../host";
15
+ import { usePluginTheme } from "../theme/provider";
16
+ import {
17
+ FALLBACK_ACCENT_FOREGROUND,
18
+ spacing,
19
+ } from "../theme/tokens";
20
+
21
+ export interface TabItem {
22
+ id: string;
23
+ label: string;
24
+ /**
25
+ * Optional abbreviated label for compact viewports in fit mode.
26
+ * e.g. label: "Interactive Controls", shortLabel: "Controls"
27
+ */
28
+ shortLabel?: string;
29
+ icon?: string;
30
+ badge?: string | number;
31
+ }
32
+
33
+ export interface TabsProps {
34
+ tabs: TabItem[];
35
+ activeTab: string;
36
+ onTabChange: (tabId: string) => void;
37
+ mode?: "auto" | "fit" | "scroll";
38
+ style?: StyleProp<ViewStyle>;
39
+ }
40
+
41
+ /** Minimal structural views of the DOM scroll node react-native-web exposes. */
42
+ interface WheelScrollEvent {
43
+ deltaX?: number;
44
+ deltaY?: number;
45
+ preventDefault(): void;
46
+ }
47
+
48
+ interface WheelScrollNode {
49
+ scrollLeft: number;
50
+ clientWidth: number;
51
+ scrollWidth: number;
52
+ addEventListener(type: "wheel", listener: (event: WheelScrollEvent) => void, options?: { passive?: boolean }): void;
53
+ removeEventListener(type: "wheel", listener: (event: WheelScrollEvent) => void): void;
54
+ }
55
+
56
+ interface WheelScrollInstance {
57
+ getScrollableNode?(): WheelScrollNode | null;
58
+ }
59
+
60
+ export function Tabs({
61
+ tabs,
62
+ activeTab,
63
+ onTabChange,
64
+ mode = "auto",
65
+ style,
66
+ }: TabsProps) {
67
+ const { Icon } = getClientHost();
68
+ // The tab strip scrolls horizontally, so it must be a plain React Native
69
+ // ScrollView: the host ScrollView is a vertical sheet-gesture controller
70
+ // and collapses/crashes when used horizontally (#219).
71
+ const { colors, resolveRadius, touchTargetMin, isCompact, alpha } = usePluginTheme();
72
+ const scrollRef = useRef<ScrollViewInstance>(null);
73
+ const tabLayouts = useRef<Record<string, { x: number; width: number }>>({});
74
+ const [viewportWidth, setViewportWidth] = useState<number>(0);
75
+
76
+ const radius = resolveRadius("sm");
77
+
78
+ // On compact/mobile or with <= 4 tabs, auto mode defaults to full-width fitting track
79
+ const shouldFit = mode === "fit" || (mode === "auto" && (isCompact || tabs.length <= 4));
80
+
81
+ // Accurately center the active tab inside the viewport ONLY when activeTab changes
82
+ const prevActiveTab = useRef<string>(activeTab);
83
+ useEffect(() => {
84
+ if (!shouldFit && scrollRef.current && tabLayouts.current[activeTab] && viewportWidth > 0) {
85
+ if (prevActiveTab.current !== activeTab) {
86
+ prevActiveTab.current = activeTab;
87
+ const { x, width } = tabLayouts.current[activeTab];
88
+ const targetX = Math.max(0, x - (viewportWidth - width) / 2);
89
+ scrollRef.current.scrollTo({
90
+ x: targetX,
91
+ animated: true,
92
+ });
93
+ }
94
+ }
95
+ }, [activeTab, shouldFit, viewportWidth]);
96
+
97
+ const handleTabLayout = (tabId: string, event: LayoutChangeEvent) => {
98
+ const { x, width } = event.nativeEvent.layout;
99
+ tabLayouts.current[tabId] = { x, width };
100
+ };
101
+
102
+ const handleContainerLayout = (event: LayoutChangeEvent) => {
103
+ const width = event.nativeEvent.layout.width;
104
+ setViewportWidth(width);
105
+ };
106
+
107
+ // Translate vertical mouse-wheel motion into horizontal tab-strip scrolling
108
+ // on web, while allowing the surrounding modal to scroll at either edge.
109
+ useEffect(() => {
110
+ if (shouldFit || Platform.OS !== "web") return;
111
+ const instance = scrollRef.current as unknown as WheelScrollInstance | null;
112
+ const node = instance?.getScrollableNode?.() ?? (instance as unknown as WheelScrollNode | null);
113
+ if (!node || typeof node.addEventListener !== "function") return;
114
+ const onWheel = (event: WheelScrollEvent) => {
115
+ const deltaX = event.deltaX ?? 0;
116
+ const deltaY = event.deltaY ?? 0;
117
+ if (Math.abs(deltaY) <= Math.abs(deltaX)) return;
118
+ const max = node.scrollWidth - node.clientWidth;
119
+ const next = Math.min(Math.max(0, node.scrollLeft + deltaY), max);
120
+ if (next === node.scrollLeft) return;
121
+ event.preventDefault();
122
+ node.scrollLeft = next;
123
+ };
124
+ node.addEventListener("wheel", onWheel, { passive: false });
125
+ return () => node.removeEventListener("wheel", onWheel);
126
+ }, [shouldFit]);
127
+
128
+ const renderTab = (tab: TabItem) => {
129
+ const isActive = tab.id === activeTab;
130
+ const displayLabel = shouldFit && isCompact && tab.shortLabel ? tab.shortLabel : tab.label;
131
+
132
+ return (
133
+ <Pressable
134
+ key={tab.id}
135
+ onPress={() => {
136
+ onTabChange(tab.id);
137
+ }}
138
+ onLayout={(e) => handleTabLayout(tab.id, e)}
139
+ accessibilityRole="tab"
140
+ accessibilityState={{ selected: isActive }}
141
+ style={({ pressed }) => [
142
+ styles.tab,
143
+ shouldFit ? styles.tabFit : styles.tabScroll,
144
+ {
145
+ borderRadius: radius - 2,
146
+ minHeight: Math.max(30, touchTargetMin - 8),
147
+ backgroundColor: isActive
148
+ ? colors.surface2
149
+ : pressed
150
+ ? alpha(colors.surface2, 0.5)
151
+ : "transparent",
152
+ paddingHorizontal: shouldFit
153
+ ? isCompact
154
+ ? (tabs.length > 3 ? spacing.xs : spacing.sm)
155
+ : (tabs.length >= 3 ? spacing.sm : spacing.md)
156
+ : spacing.md,
157
+ paddingVertical: isCompact ? spacing.xs : spacing.sm,
158
+ },
159
+ ]}
160
+ >
161
+ {tab.icon ? (
162
+ <Icon
163
+ name={tab.icon}
164
+ size={isCompact ? 11 : 13}
165
+ color={isActive ? colors.foreground : colors.foregroundMuted}
166
+ />
167
+ ) : null}
168
+ <Text
169
+ numberOfLines={1}
170
+ style={[
171
+ styles.tabText,
172
+ {
173
+ color: isActive ? colors.foreground : colors.foregroundMuted,
174
+ fontSize: isCompact ? 11 : 12,
175
+ fontWeight: isActive ? "600" : "500",
176
+ },
177
+ ]}
178
+ >
179
+ {displayLabel}
180
+ </Text>
181
+ {tab.badge !== undefined ? (
182
+ <View
183
+ style={[
184
+ styles.badge,
185
+ {
186
+ backgroundColor: isActive ? colors.accent : alpha(colors.foregroundMuted, 0.2),
187
+ },
188
+ ]}
189
+ >
190
+ <Text
191
+ style={[
192
+ styles.badgeText,
193
+ {
194
+ color: isActive
195
+ ? colors.accentForeground || FALLBACK_ACCENT_FOREGROUND
196
+ : colors.foregroundMuted,
197
+ },
198
+ ]}
199
+ >
200
+ {tab.badge}
201
+ </Text>
202
+ </View>
203
+ ) : null}
204
+ </Pressable>
205
+ );
206
+ };
207
+
208
+ // 1. FIT MODE: Closed bounded track frame
209
+ if (shouldFit) {
210
+ return (
211
+ <View
212
+ style={[
213
+ styles.frame,
214
+ {
215
+ backgroundColor: colors.surface1,
216
+ borderRadius: radius,
217
+ borderColor: colors.border,
218
+ },
219
+ style,
220
+ ]}
221
+ >
222
+ <View style={styles.trackFit}>
223
+ {tabs.map((tab) => renderTab(tab))}
224
+ </View>
225
+ </View>
226
+ );
227
+ }
228
+
229
+ // 2. SCROLL MODE: plain React Native horizontal scroller.
230
+ return (
231
+ <View
232
+ onLayout={handleContainerLayout}
233
+ style={[
234
+ styles.frame,
235
+ {
236
+ backgroundColor: colors.surface1,
237
+ borderRadius: radius,
238
+ borderColor: colors.border,
239
+ },
240
+ style,
241
+ ]}
242
+ >
243
+ <FallbackScrollView
244
+ ref={scrollRef}
245
+ horizontal
246
+ nestedScrollEnabled={true}
247
+ directionalLockEnabled={true}
248
+ keyboardShouldPersistTaps="handled"
249
+ showsHorizontalScrollIndicator={!isCompact}
250
+ style={styles.scrollView}
251
+ contentContainerStyle={styles.scrollContent}
252
+ >
253
+ {tabs.map((tab) => renderTab(tab))}
254
+ </FallbackScrollView>
255
+
256
+ </View>
257
+ );
258
+ }
259
+
260
+ const styles = StyleSheet.create({
261
+ frame: {
262
+ width: "100%",
263
+ maxWidth: "100%",
264
+ borderWidth: 1,
265
+ overflow: "hidden",
266
+ position: "relative",
267
+ justifyContent: "center",
268
+ },
269
+ trackFit: {
270
+ flexDirection: "row",
271
+ flexWrap: "nowrap",
272
+ alignItems: "center",
273
+ width: "100%",
274
+ padding: 3,
275
+ gap: 2,
276
+ },
277
+ scrollView: {
278
+ width: "100%",
279
+ maxWidth: "100%",
280
+ },
281
+ scrollContent: {
282
+ flexDirection: "row",
283
+ alignItems: "center",
284
+ padding: 3,
285
+ gap: 4,
286
+ },
287
+ tab: {
288
+ flexDirection: "row",
289
+ alignItems: "center",
290
+ justifyContent: "center",
291
+ gap: 4,
292
+ overflow: "hidden",
293
+ },
294
+ tabFit: {
295
+ flex: 1,
296
+ flexShrink: 1,
297
+ minWidth: 0,
298
+ },
299
+ tabScroll: {
300
+ flexShrink: 0,
301
+ },
302
+ tabText: {
303
+ textAlign: "center",
304
+ flexShrink: 1,
305
+ minWidth: 0,
306
+ },
307
+ badge: {
308
+ borderRadius: 9999,
309
+ paddingHorizontal: 5,
310
+ paddingVertical: 1,
311
+ minWidth: 16,
312
+ alignItems: "center",
313
+ justifyContent: "center",
314
+ },
315
+ badgeText: {
316
+ fontSize: 10,
317
+ fontWeight: "700",
318
+ },
319
+ });