1import {
2 resolveProviderSkillSourceKind,
3 type ProviderSkillSourceKind,
4} from "@t3tools/client-runtime/providerSkills";
5import type {
6 PullRequestContextMetadata,
7 ScopedThreadRef,
8 ServerProviderSkill,
9 ServerProviderSlashCommand,
10} from "@t3tools/contracts";
11import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger";
12import { memo } from "react";
13import { Pressable, ScrollView, StyleSheet, View, type ViewStyle } from "react-native";
14
15import { SymbolView, type AppSymbolName } from "../../components/AppSymbol";
16import { AppText as Text } from "../../components/AppText";
17import { GlassSurface } from "../../components/GlassSurface";
18import { PierreEntryIcon } from "../../components/PierreEntryIcon";
19export type ComposerCommandItem =
20 | {
21 readonly id: string;
22 readonly type: "pull-request";
23 readonly pullRequest: PullRequestContextMetadata;
24 readonly label: string;
25 readonly description: string;
26 }
27 | {
28 readonly id: string;
29 readonly type: "path";
30 readonly path: string;
31 readonly kind: "file" | "directory";
32 readonly label: string;
33 readonly description: string;
34 }
35 | {
36 readonly id: string;
37 readonly type: "thread";
38 readonly thread: ScopedThreadRef;
39 readonly label: string;
40 readonly description: string;
41 }
42 | {
43 readonly id: string;
44 readonly type: "slash-command";
45 readonly command: string;
46 readonly label: string;
47 readonly description: string;
48 }
49 | {
50 readonly id: string;
51 readonly type: "provider-slash-command";
52 readonly command: ServerProviderSlashCommand;
53 readonly label: string;
54 readonly description: string;
55 }
56 | {
57 readonly id: string;
58 readonly type: "skill";
59 readonly skill: ServerProviderSkill;
60 readonly label: string;
61 readonly description: string;
62 };
63
64interface ComposerCommandPopoverProps {
65 readonly items: ReadonlyArray<ComposerCommandItem>;
66 readonly triggerKind: ComposerTriggerKind | null;
67 readonly isLoading: boolean;
68 readonly error?: string | null;
69 readonly onSelect: (item: ComposerCommandItem) => void;
70}
71
72function PopoverSurface(props: { readonly children: React.ReactNode; readonly style?: ViewStyle }) {
73 const baseStyle: ViewStyle = {
74 borderRadius: 16,
75 overflow: "hidden",
76 ...props.style,
77 };
78
79 return (
80 <GlassSurface
81 glassEffectStyle="clear"
82 tintColorClassName="accent-glass-surface"
83 style={baseStyle}
84 >
85 {props.children}
86 </GlassSurface>
87 );
88}
89
90const SKILL_SOURCE_SYMBOL_BY_KIND: Record<ProviderSkillSourceKind, AppSymbolName> = {
91 app: "square.grid.2x2",
92 repo: "folder",
93 project: "folder",
94 personal: "person.crop.circle",
95 system: "gearshape",
96 other: "cube",
97};
98
99function itemIcon(item: ComposerCommandItem): AppSymbolName | null {
100 switch (item.type) {
101 case "pull-request":
102 return { ios: "arrow.triangle.pull", android: "merge" };
103 case "slash-command":
104 case "provider-slash-command":
105 return "terminal";
106 case "skill":
107 return SKILL_SOURCE_SYMBOL_BY_KIND[resolveProviderSkillSourceKind(item.skill)];
108 case "path":
109 return null;
110 case "thread":
111 return "text.bubble";
112 }
113}
114
115function groupLabel(triggerKind: ComposerTriggerKind | null): string | null {
116 switch (triggerKind) {
117 case "pull-request":
118 return "Pull requests";
119 case "slash-command":
120 return "Commands";
121 case "skill":
122 return "Skills";
123 case "path":
124 return "Files";
125 default:
126 return null;
127 }
128}
129
130function emptyText(triggerKind: ComposerTriggerKind | null, isLoading: boolean): string {
131 if (isLoading) {
132 return triggerKind === "path" ? "Searching files…" : "Loading…";
133 }
134 switch (triggerKind) {
135 case "pull-request":
136 return "No matching pull requests.";
137 case "path":
138 return "No matching files or folders.";
139 case "skill":
140 return "No skills found.";
141 case "slash-command":
142 return "No matching commands.";
143 default:
144 return "No results.";
145 }
146}
147
148const CommandRow = memo(function CommandRow(props: {
149 readonly item: ComposerCommandItem;
150 readonly onPress: () => void;
151 readonly isLast: boolean;
152 readonly isSlashSkill: boolean;
153}) {
154 const iconName = itemIcon(props.item);
155
156 return (
157 <Pressable
158 accessibilityRole="button"
159 onPress={props.onPress}
160 className="flex-row items-center gap-2.5 border-border px-3.5 py-2.5 active:opacity-60"
161 style={{ borderBottomWidth: props.isLast ? 0 : StyleSheet.hairlineWidth }}
162 >
163 {props.item.type === "path" ? (
164 <PierreEntryIcon path={props.item.path} kind={props.item.kind} size={16} />
165 ) : iconName ? (
166 <SymbolView
167 name={iconName}
168 size={14}
169 tintColorClassName={"accent-icon-subtle"}
170 type="monochrome"
171 />
172 ) : null}
173 <Text className="shrink-0 text-base font-t3-medium text-foreground" numberOfLines={1}>
174 {props.isSlashSkill && props.item.type === "skill" ? (
175 <>
176 <Text className="text-foreground-muted">skill:</Text>
177 {props.item.skill.name}
178 </>
179 ) : (
180 props.item.label
181 )}
182 </Text>
183 {props.item.description ? (
184 <Text className="min-w-0 flex-1 text-xs text-foreground-muted" numberOfLines={1}>
185 {props.item.description}
186 </Text>
187 ) : null}
188 </Pressable>
189 );
190});
191
192export const ComposerCommandPopover = memo(function ComposerCommandPopover(
193 props: ComposerCommandPopoverProps,
194) {
195 const label = groupLabel(props.triggerKind);
196
197 return (
198 <PopoverSurface>
199 {label ? (
200 <View className="px-3.5 pt-2.5 pb-1">
201 <Text className="text-3xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted">
202 {label}
203 </Text>
204 </View>
205 ) : null}
206 {props.items.length > 0 ? (
207 <ScrollView
208 className="max-h-[180px]"
209 keyboardShouldPersistTaps="always"
210 showsVerticalScrollIndicator={false}
211 >
212 {props.items.map((item, index) => (
213 <CommandRow
214 key={item.id}
215 item={item}
216 onPress={() => props.onSelect(item)}
217 isLast={index === props.items.length - 1}
218 isSlashSkill={props.triggerKind === "slash-command" && item.type === "skill"}
219 />
220 ))}
221 </ScrollView>
222 ) : (
223 <View className="px-3.5 py-2.5">
224 <Text className="text-xs text-foreground-tertiary">
225 {props.error ?? emptyText(props.triggerKind, props.isLoading)}
226 </Text>
227 </View>
228 )}
229 </PopoverSurface>
230 );
231});