PF+@?J&7StOT1^BPn%bE&iIz$Hzb7+p@mEq50Kg>;H0ZDR-_M)TGlC7be|LO+
z=MGi!W^O20xXJT-C_=$z&eb}TsbK$4f3r{kz{Kpc;^wpa0EVY0=MLgCY_~WL6j$ha
zt9nDTckex$Ao}-Suc9jgR|KvIToJe;a7EyXz!iZj0#^jC2wV}kB5+0Eiog|tD*{&p
zt_WNaxFT>x;EKQ%fhz)61g;2N5x637Mc|6S6@e=PR|KvIToJe;a7Ez1lK|{^F7Vc2
z>@UUj_}bUE9uM!Z7b!(3S<}~i%~tBDOGFybzMDE5B3kjfHOf0)%Q7Si^Sj8SUwz239KNHmxn0uUzK>J)f6JN1#RM6b13R4$MlcafGG(+@o9k~|
z#$WP(R`X?gemneWy;MIy969=3@}26m%dPkt7!n*YS0#tD#5b7mgK^+Y(dgUpomWmA3NS*Otva&nT;!Yb3JVmR_#CbJ+gS(>
z@$WeX9r14SAzy+IU@b-d9Kr4y=b4`xxJ~zRKb3_no)3JW{Kx|F*sKxC;7K!gy$jd$
zh2vV~G-p&a8=t;Z_c7{%+Ia{D_@t#_xz~o%rjQsC%
zNVEyzw!*`1r5}&d@1YU573|y@VJ)$X;lXzu>v-=zBhU-Dhp_O`{-o)n$UrNPPYs{V
zRNUBbrX>3Uy3PF#(pk*L{XGd{@qbD!$U7lhZei(Qof?X;+ugj)0dmhSxlzC>D7ki45+syT&d|+II}V
zBxzMS$1cO3wx}mG!31@aD0VtM)t{GFXU`gCy#IzffL;%5c$#s1YIRb?-a=}3k0X0m
z8e>Mj|CtMq5c(zKhDMUrywJyX_E_E2f>-xZ4>AQ$#5bnK5Bq&tGVx+VWZPY>_ysA2
zR+8|IbNr^SYiPn_f4$^g-R>K2pT8wA!2nXB(fCNkuY`8gb9ac#hzOFfeum|^8Txu|
z<;_=X9^ERrrQ!JkLAX^ohWk$lP>ER2QCE7j~+ShcTL3=(tm}5yclat^gg}yiY4g*b(^q
zK5Jj=HU%ibO#L#_C>(phYSW}REb)mER^{#ao9LIT1j3XH{>tm0GX&*!c~5Gy#p8}l
zb4fI(%0fM7oGj|IRB^|tw4v@c@Sc2B=Nt{tfm`g?pl?K<8;4AOy7kGO;;`akD7v<2G9
zaW(-za+^+U7&4d+QUo{)VuvF87GH2su_o@{U1sX~Y|A`Zddq}4ub(8%bUXGplVs!L
zmfb<9?ers@6UBxR%ODTwUYy9_2f>hp#`Qn~0>1X|o2{i}r4r%CfI^+A<0FN{)?LQ;
zljYYt%@0@EcuAejT%R9sqRn|ldrM>G@f{RtcUngM
z5Qwip-yU4vMjRmgY?Cn)|J}A-9>`&4_^G-@^V0W+zPIQtS#*gQY6hfaxF&D!@f}_d
zrhcSTAkgix!6j=z#f#0k+FM^g
zTEK_mR^}~kJKrYNe_~(BSyu0!B&$(`?r+h9
z2|1syr@Bqwu@CyNbFwa}ym6D`6N_~>zN?c`N-cMJEg85i)G#dO9@OT6T22V-{xqETdU$`Zq5#b!$xK&pYiuCOag+%Ty#q`QdDN6<;ZZc
z-1KnZ`C#cR#fAGQeWl0uC5~Qg!-s4+pSMywJP#fQ{d$|6B-py%yHQOzcM)J~HdgG^
z%1t$XVh8t-;bc`DJB6;XM|;FrR<)#?gK4X1nVQTAxYq=XdKSr7$s#;c&o5fJ*rdi^
zLc2c-ah^+UO+MCiF9}M1EiX^KE+XMlU~wBgajlQKtL_eS{K-IzCtl3z!jTO7IW4tG*I
zd2c==EyJgg=)v;6%}j9a)bCpl-#13*4BiXr{}Q2-{$piru!M7sX$C9(B~T#LpIH#}
zpmNUv`yef4rT5dVWy!ETf!JEfy_fq2hIG$^BOz}CdVhKXu
zwBM8_m*)8u(l@PHU%K@41=Co%RfwQS0#ATbUHff&sB%yJkd&%(!m9
zUp@mSdypk+TAqT5-Um{ia?^0u)n}xt-u-x5w(=o8s^e$kAn60_HQ<~bgUoP=(Guih
zJKd$=A%i`DQDKZ>OOv1R_2@Gd_1ymMla2?B&F1h$XET-V)|C$3I7fpOH)?d<4rf&F
zPCHY{wG8=G0;2E|Ai+!N6Qc83=Yx%*wU}LlW_JIdI^-UB3b-Mgsk;$vHBQ)U7{e%?
z^GFdCD{b;iRud9#_nXHPXytwF1(_0@OdU}A2a5geq#`N&C?%P!N
zhYkGO57?QRYW8AB-S3aZ1;qR)y>-nrSLMcy&n2~T)_YlKZAZEQKLOHws_XhHi9Z!y
zx!UZ7Zb|Ui+PF!r9hyhJX{kF0!X$0PHNAuJS=g3#`i2M>=#pdv`nqrB8>?uvH?;%l
z%yX4>k3%GhZ+2Lg`#<|2lan0HQwNpH$Eh`vkrN3Md)~E|j2Ll7bUp$;;myMgWVJ#G1B+1hR7P%!`{ONH<)*&hE*yZH{10&gLIl-|Dx&s(i-Kp>72>5
zML(>)8vfhv{NptRhK|hW_Q7q%!$jE);+mfM!((3aSi~a^79oChU+${jw&FI{^pp9u
zz{h=B>6$T*kUbK)ubgY?-C6gl$V6JdeKTkH`uuy-bTH=D)&;1}9yT=ZqM}`0KO3^6
zb0<;C7mod1C6^bL9WBE8gYX5R3;JHKfCp3~A#iNE;11U;@r=*%euq42!A1#H7i?zA
zs_|YUdFe62<;FONrE#Wy9xr8BjC1Ad!~T#h{nrmh@Sk3vOEY30ty~e`YX6WXcj_RS
z!UYfCj#>OU!1ID~DBcbCnnsp8w8)}^g|V;eL2K%wxRS?*>Svynx-bM{H00frW0(6e
zh18K^uLGTis4rHBGY~d8sV6RsbaqB({&)3Wh$+>os)g9D!<5@2buyIioIM=?wpHwt
zhMoKh=RAr^VOAh3S>Qbk1eCBuQY_zTt*xWZzD+AN7AOu+%%l@jA9B3G#>%sB`L=Yl
zc!c%#tE3z`%aBc4Th31|>3}u5}hoz0CZfkW|@D(n96dSAPH0dq3BOdKWLOu>}oO5@tO3{k$m?
zA@gpdW1BlYZzVHQ-4^+CP0Cv|#D#Y5x9&-ign9Yc*3b-2z{{g^j-#Hn);Gw%wO;F%
zbBI(9#YK%4eApjw5Mb=#uiBI^PZrj8jts5udfI>$MEUG5(b?DaeL5iyTeM$qU1_C|
zj^*S+)d>
zta`T2luP9Pz0JGt!dFeYZ-}s7bQMum*_Ww)91h$fXZ&E;4$1yF+F&&SWBU0TqL7Mm
zP(Tl69#R|TZ75mnndnTC7{La7XEs@XyKq%?_oMk|$&R0l$hjD5*xfn+k`S5__jKp&
zm-<{x>A~9?`(08B`;Jtp
zHxTIB?jK6#0vZ23Ls$vzQV#Y%A`I;|eZX8JtQ(M$=+)@Glf>w~CAFq#h|H=$JeE4h
zWPzN;`DG>~lfDt3%)sF
zbHq|lcBIQq!1Bc*mG!qqCr#0x@+tHEu5-M7qCHH?-jpkIzeU%NpZ#Z11kJoWQ|50<
zqLiB~eRV5ovoTt;Hy*P4ChK>LMQ2SLh<{g;Z9JQPIv}k6liKoH;ZvvJZKV*M}!s?@8yt$W`5
zlV=Kt)GhWr+Mhbo>2ej)XOwrQ_zU?O=*0ZqQ+l9@#%TznM7l|vr*ct2o<8yq_3z8F
ze^Z>Ygb!v^D0QU0*`3hKVf_W_lDZKBf
z{)E%_raX@RR&rj6+p5c6dE*s>x90UG%dw5~(Uk7-h_>Ak<5R#H{bRkvce$P#awM$4
zM|HP8^s(-0le!R&aSfL+_(_nNy3na-_I1RSk(oE&e&ib0+tKzeL|7xh0{m%;@btMJ
zwWpn(%?adFX=FfS!VAVo``&0CxpT|wU$$I2cYmp&a9ck-aGwQC&6^}TeVcFe*DnnE
zjt`@XiNq!dTpYHnJ6p&rUP&_~)mR>}m1AYnBwR8j7ahYwU@U&`hip7~M&&Mw5AxDRJ&kBs8p$M%UZ-G22VRCa)bpBV1a
z^!!T}lLCTVZut}bdsVKVsb45w=}ds8jhK^QQ!7v42ByMDMq^Qf%A;IUv7=uGiit07
z^=iJQv^1s2w+W9z!!$ams_8LrKE%75PNW$-GIu3Y?33r`s?ajYbGXTIzaYd{XCbl{z%`pFhzCU$JUG1Agr~_I3efD?U0z_BO~v=8bjR2#lB1_
z3JeAhupt5PxtqRMt6{Q6Q^L(87&+K2gEK~arMUI1k98HNe*iX5X%VEh5n)TPxJM$0
zqVi#)zP1%?Lnb<6cPyThMoR8@{`X+YfU0HhOVE-fsJE*qnmiFiN}A
zzTZdDQURo69AisF{I5r->&mziLHW9I@_=HWE-K;khXA@MMj4CSWUxji)zi`I*RH9R
ze||ujehgL|R1+!7N&kG$#-J6?A}Y*{2roRN@8RY9>eKjX%Jclv-nz!79`@of5>G{?
zGQNmT4!dK8$VYK4^E$0lV~M(h_B3QnayNpD@>DsZ
zg@x&bl0GAfp;e`2<#H<-o$hf$B%pZYX+#LRJf0|=OPJL$vq3v2#MeNdBkY=$MZ-!{
zS@Ze@pLMzsoQcHdlDRh}q*bq9xONN-_qh+zOa^Ozv~V6_&5~7F1nj5{(FhSUEt8%6
zv|ZlMrd$c&J${rn67+$k_?aggNF04c)Z0^o+nhONPYM#^uuRP7qoK`d<87@%9y#5D
zpC>%OX=t7#cZ?u|7$C{?e6&j2v|eRM47g~mFfDH7O;B}AzO0XkGsdZK`>!S;Qneb*
ztd;Z;!FCdYt3N~foGUgy*;Ygo<#G!4%|3-cEVkl2E^ORgh?fXfFZX}<(=xqARzZB_
zm#V0QE-U8d$Ktctv&JDZpkb&vtD&@H2OVG3MJedVIWN~S{%7)g#?+<#i=(x7e?b(z
z%DG6Nu!=QhWzOBi^Z@9@Xh2%L+2Q`hsf09V#}QB$3bNH9`Yj(bTjQ@u>(J@8H2
zcS_Fw+3B}m_kFKGp0IX=^gdI5U6;}ebRH>a)V8N*H}SaYJ{YNHkj`5Yn!glvMzh!b
z<7c^EI^b>ozPpLw6ry_P<*ewWF<3F=J-jV2F5M-wmWd^TJgCHC(0lolpAcWP!8Co7
z@acSn!h4ZB;T3edEKz^K5ng}cw87K5dYYr7HX-P5*CU4x(&!DnTV+&poY|0K(T^3H73;R5u7lx|a&}6JPPN%HHPcWj^K;;3O
zMOBj>h1@UX!Uyz|r#dSI)*4?s7;+`7r!U?n4}BXj8`dp4^&>j@Rhd+$X>XquvVUB!
zLe<5SGnPmQHDHn<;57ymB9lJoipJS&>3>A#v5LPab}40fK}oRQ?s)i!@5gC#tjh;e
z$&h{PkZw7`?#{P~q0=)J{*Rj}NL&+o^#-HF_+)qlf7w+Rb%&OnzDu*%aoqieQx!!D
z66u#&uRe*^ri`wA$G`^2qdrtl50%Myeet#o+4LzjzpQiUTf}{2>aoA$g2V6}Wwk8f
zM=w~ShCu1Fb2k1jv>v8GbN;X*37e8$wbyhmA^mJVD1D)w#%&$+~vWym(43qdIZ(7%(=VNn)P&A
z;raXS#>WZohGcc_JpWt}IPo&Qo>94z(R=YWr^M`7p~cYeraHpJG@swus4wMJnLu#%D@T{QDlT
zV>}FcbVX?1|D;TeD3SV!lwMYvWspjCm)$s%+_@VmeB?`Y=1QyX55U$?7T
z72e0U^=mGQd&|4MqGq^IZTw-YlEDQ2YPQsIU~EU{TL|i(Ng2l0uX7xr3BESSe*Zit
z!luV2;E`!TMM5OQ*hsZlc(iI-7
zHb}G}ogmI{p5(OPZxw!?fs!gcBk+1}2t47tX~B1&f!2!mdiLmgTd^#yCUY}a<3N9o
z?<8pgR}$9dK;rh%+L~(N8NM4PD`A9(tywy6pzf_qH_7if|59tE0aN5*F_05~!mIgQ
zweeHrNbb%g*KCo|TOSaYdg`opv-VxWx}&!m5#kd)uK~m6{=^y==LE8jpXsrM&0M^t
zy;UbZ{-N;d2-!3j?p;>jiC^bI**v$xS?f%t8F|?SP2sSQgxr(6R(`03ui)wOs0{YW
zHu&UX;COyFYmHVObL&f|#;`&)4oHsh`s9EXsz6$ninc$Rp1Si(qK6r(n>nm_fjQ@!
zQY8ZqQU)7R_TcXDK3t1)3y}*8EQV%mxYJsN-!dHQ!<+BhbA+XPb|nb^PIe+zGEP*g2#a;7PEEX5XJv^YRNF>~`_S!BT2!NZiNp)wk
z?QVjeJ6d3ExbHrhmBA5dPxy_>>-S8bfMO>CgX+${N7%5aZ;Knu_Rg6;lu~>26x{SM
z;l9G)P+UVhmPd?;8dK!RejA_B`aQ>2{ae8tIEGC2(1n@mt}4TsT_<+ZXM@Dkje(Q%
zkp|}wb@oJig|e??R0kScb|t&dXWP|l`TUU@Vxtvi-?j5Xb}-hgA(01=fbjZBo9OR}Thgcc*=egc^8_7y%W&NmBl-cUs(=G3DNv#Zp$rqwkVFLR&;Pm^5w4Ivc$Q`(dsS@u-
z-2AskCRc@aP`kGf_=`N}A*L|PiZSI!0Guau7
z7RZBqPoZ2SS8^Z>oqcd&joiHTM)X{gwYnS=y;I2-^_-!|9O)riV|6jN1I9~KWOg=<
zV6IO2JRWTU>8b*t<0Q=ldQ1~SnB;fQL^36OvpKwzfZ=ac@5b@AZ041*wm&s$#WH(_
z9R?>6R@~{foH@h4<;QX@@jVxY*w=IoTIzJxDfhY-F@%q^{1S@8|5w$efQa{GmD)u
zJq7%1iZ5Hiy-HK4R_fVQnmOWEeL8iik6PpFmr=d#u9A;n4IsnPQ;@iU#mUAl_q|uL
zwve#%nv`d`=$Kf2!r0UkuJ+R&(g%!$^3_L41c#Z$@8AAVltbzjTKj1@y*BH{BFKHo
zq8HzhpKhXDt*DYo9+J)$I8n~)pPKYEy_Un%m`iSxiavX%^Ds$3q7-j!%xxYys647!1
zF`X0vueqGQ#sYv0U~*ZG5$7C;6ApIu@(m`C@ToXi;7~)FBK`6IDk3=t1YK5KR{b
zBqAmuB_pS}PUCEY_VV%d^LL=&ve2;b4e+G6u|R_H!FUIHt9oJ3J~(r90QRyppb0ME
zvhbt>1&cKA4#b_+es4|N+1KZ?B&9T^H2;1dg98QSQ71Lh8Mlmepx}_cwqX#6^9#hO
z`ugAi5}oeaIP@~=vR)v7+lO(lk53DQ-P_bad3iaZoIM>V=%rbf^?kK{08E=IXm?Z)
z#y7x$g8h_&LE8S*hkUP($~4q({knyQQ6R?Ildf{rBrw1g}@X^U0baDgfn;#sTjE9hNaz;b1nF!n?w#1*ota8izxN;3k=vSy+W&4ipp?8m0gW
zIz<8^0QIVBAQtEAO`ol7pud1`YG$UbHo?ZuA!uff@(M&dP*7WFn4kgY%U+l;6om{B
zwKl=Y#m!@m<#1T1Lg8>IXLoPFlLbPoN~kf)#|@3T_%+VUXC4k0VnalZ^9z^~NOrZ^
ztA`M2Q<_rg6C^F0`g)={o@~21Y&r)!zIU6Gj6ax2BLPEUIeSI=U7X8Zt;?^5pQr_+u*&
zqVa-y`Z5)#3xp^a7aw$R0NNYlBX{kMF_FY0^qj}RUvvFSYNeg~i
zODk(z!7qgKIRQ4tS~6_z^p-K}r372zY6x|>hL{*kN=g-!{vD1Ghp9-YOTrLhDxydr
z>8cuBOq_s#aCpOUQbm;orRzii0!VF1sEcYK;Ns#iX=ybOXn_0@G6K1Pf{=cpiA3cwPVs;K*gIyuFeDz|mI#p7&qn@-BCEuKxkU
CV%atT
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
new file mode 100644
index 0000000000000000000000000000000000000000..0286763cfa763ed6fba34f80edb8e6196f402dd8
GIT binary patch
literal 22481
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
CV%atT
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.unsigned.shortcut
new file mode 100644
index 0000000000000000000000000000000000000000..25f2bb2daaeaa1d3fd27de35fdd8d22a486b1216
GIT binary patch
literal 1948
zcmaJ>TWl0%6rPRF@XI+iqJf<8=4$?$Ftpb!OJK
z5P6cANMd5527EBp#1~_Th7e8EXn4?9+X4xR;mHS$F(#Urn2_N4cMH2{5h%ua0EHX3eYN%G2{nu$>h3QcMe;@a6PQmL&I{MDXTbRa(xd(a~01I
zi}i5Og`_ddx2-7$1*cj$gGF3&N83TQ23L;NQLxftZ(9sk(LFbWMh8Pw)g3Dc5Wv{k
z$~j`YcC`*6jOKpNd;UKm7y7naZfk4EssaaXZ4G%12pXzbuh^b%a=TjM>Y?mhEX9LHYj#k6J$T6oLq#fE`3Sa~My4baXHc=LI7VdCgDaw`ud&;%srv&`;>~a=*##
ziK|jtP?G{LYog988A0HaYE0#gbRr{af-a}x#!~mAkL?>Bvz$86w7Fp95J>CTXDlol
zhPt$KSI=IP+hm2IRjgD2SfI_1SZ*1O1r4$ndv}kW75aO`R~P&C+YS#%;FaF-VAb^A^~}iKLLubMghhj4PYC><<2Lc>gS^-2MXWE`qO$ZZ1XtpP7yC?v-PwxXr!rsMR@wy}{eX;F3
z;!s^Jmwn4E*=JA#;$(~Ml~>I9!igr?%^(0src7?vY5;sxLsl3?K-u*JozFab@VP_J
z1BnB^SFgc
zO?n&psQ0A?h{gb{$Z8O+QprWLKC0So3Hdh;cMiVrV)jUOC<~JbZZWHSU;qKiRS<&u
zum-y2Ah%@sc;VX;U`Jr?+JkvJbkIqdMW|A!Aa|tht_6@vTFeNBEc1zk4&y9{3a`a7
zabA=)Nd<^$K~@0bFxK}d=zDBg>Kn=BM^D_dE4(`kP$K2aqu5T6m>5K8;+BEJc1NH;`M%{bWCR
zh|H1!IY*u&-zG1Sm&vQ-7vwkOHS&A%CV7kejr@b!Om$KHlt_(JQ&gF9D36+>-k{#0
z7N`%X%hVO>26dbIlU_q_q~r8S+N4W#m3HZQ`Yrlh`b+vd`WF2cv!3ZsX7nRCo}<`d>JbA|be`I@=L+-Ck}?ywP-WEr-bmDxCZfE{M@>;zk4FR*X2
V7uich<3~oCPvYg3=gNb9?;ka?eK-IB
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Services/AIAgentShortcutInstaller.swift b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift
new file mode 100644
index 0000000..8d973a5
--- /dev/null
+++ b/OSGKeyboard/Services/AIAgentShortcutInstaller.swift
@@ -0,0 +1,41 @@
+// AIAgentShortcutInstaller.swift
+// OSGKeyboard · Main App
+//
+// Opening the HTTPS iCloud share page from the app is claimed as a
+// Universal Link and often lands on Gallery. `shortcuts://shortcuts/TOKEN`
+// opens the Add sheet in Shortcuts directly.
+
+import Foundation
+import UIKit
+import OSGKeyboardShared
+
+enum AIAgentShortcutInstaller {
+ static let bundledResourceName = "OSGExtractTodos"
+
+ @MainActor
+ static func openInstallPage(for skill: AIClipboardSkill) {
+ if let shareURL = skill.shortcutICloudURL,
+ let installURL = AIAgentShortcutRun.shortcutsInstallURL(from: shareURL) {
+ UIApplication.shared.open(installURL)
+ return
+ }
+ openBundledShortcut()
+ }
+
+ @MainActor
+ private static func openBundledShortcut() {
+ guard let bundled = Bundle.main.url(
+ forResource: bundledResourceName,
+ withExtension: "shortcut"
+ ) else { return }
+ let tmp = FileManager.default.temporaryDirectory
+ .appendingPathComponent("\(AIClipboardSkillCatalog.extractTodosShortcutName).shortcut")
+ try? FileManager.default.removeItem(at: tmp)
+ do {
+ try FileManager.default.copyItem(at: bundled, to: tmp)
+ UIApplication.shared.open(tmp)
+ } catch {
+ UIApplication.shared.open(bundled)
+ }
+ }
+}
diff --git a/OSGKeyboard/Services/AIAgentShortcutRunner.swift b/OSGKeyboard/Services/AIAgentShortcutRunner.swift
new file mode 100644
index 0000000..95d61f2
--- /dev/null
+++ b/OSGKeyboard/Services/AIAgentShortcutRunner.swift
@@ -0,0 +1,71 @@
+// AIAgentShortcutRunner.swift
+// OSGKeyboard · Main App
+//
+// Consumes the keyboard's pending extract-todos payload and opens the
+// companion Shortcut. Release builds stay in Shortcuts. DEBUG builds add
+// x-callback URLs so Console can record success / error / cancel.
+
+import UIKit
+import OSGKeyboardShared
+
+enum AIAgentShortcutRunner {
+ @MainActor
+ static func runPendingIfNeeded() {
+ AIAgentShortcutRun.trace("host.runPending begin")
+ guard let payload = AppGroupStore().consumePendingShortcutRun() else { return }
+ guard let skill = AIClipboardSkillCatalog.skill(id: payload.skillID),
+ let name = skill.shortcutName else {
+ AIAgentShortcutRun.trace(
+ "host.runPending skip unknownSkill=\(payload.skillID)"
+ )
+ return
+ }
+ AIAgentShortcutRun.traceBody("host.titlesToShortcut", payload.joinedTitles)
+ guard let url = shortcutsURL(name: name, text: payload.joinedTitles) else {
+ AIAgentShortcutRun.trace("host.runPending skip URLBuildFailed name=\(name)")
+ return
+ }
+ let textItem = URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first { $0.name == "text" }?
+ .value
+ AIAgentShortcutRun.trace(
+ "host.openShortcuts scheme=\(url.scheme ?? "") host=\(url.host ?? "") "
+ + "path=\(url.path) urlChars=\(url.absoluteString.count) "
+ + "textQueryChars=\(textItem?.count ?? -1) name=\(name)"
+ )
+ AIAgentShortcutRun.traceBody("host.textQuery", textItem ?? "")
+ UIApplication.shared.open(url) { success in
+ AIAgentShortcutRun.trace(
+ "host.openShortcuts result success=\(success) "
+ + "(iOS accepted the URL; not proof Reminders were created)"
+ )
+ }
+ }
+
+ static func logShortcutCallback(_ url: URL) {
+ let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
+ let status = items.first { $0.name == "status" }?.value ?? "unknown"
+ let errorMessage = items.first { $0.name == "errorMessage" }?.value
+ let errorCode = items.first { $0.name == "errorCode" }?.value
+ AIAgentShortcutRun.trace(
+ "host.shortcutCallback status=\(status) errorCode=\(errorCode ?? "-") "
+ + "errorMessage=\(errorMessage ?? "-") "
+ + "(Shortcuts finished; does not prove reminder rows exist)"
+ )
+ }
+
+ private static func shortcutsURL(name: String, text: String) -> URL? {
+ #if DEBUG
+ return AIAgentShortcutRun.shortcutsRunURL(
+ name: name,
+ text: text,
+ xSuccess: "osgkeyboard://skill/shortcut-result?status=success",
+ xError: "osgkeyboard://skill/shortcut-result?status=error",
+ xCancel: "osgkeyboard://skill/shortcut-result?status=cancel"
+ )
+ #else
+ return AIAgentShortcutRun.shortcutsRunURL(name: name, text: text)
+ #endif
+ }
+}
diff --git a/OSGKeyboard/Views/AIAgentSkillsView.swift b/OSGKeyboard/Views/AIAgentSkillsView.swift
new file mode 100644
index 0000000..72884fb
--- /dev/null
+++ b/OSGKeyboard/Views/AIAgentSkillsView.swift
@@ -0,0 +1,341 @@
+// AIAgentSkillsView.swift
+// OSGKeyboard · Main App
+//
+// Catalog of AI Agent clipboard skills. Enabled cards (max 8) appear on
+// the keyboard after a copy; long-press drag reorders that row. Export
+// skills confirm a companion Shortcut before they can occupy a slot.
+
+import SwiftUI
+import UIKit
+import OSGKeyboardShared
+
+struct AIAgentSkillsView: View {
+ @Environment(\.themePalette) private var palette
+ @ObservedObject private var config = ProviderConfig.shared
+ @ObservedObject private var store = AIAgentSkillLayoutStore.shared
+
+ @State private var viewingSkill: AIClipboardSkill?
+ @State private var showFullAlert = false
+
+ private let columns = [
+ GridItem(.flexible(), spacing: Spacing.sm),
+ GridItem(.flexible(), spacing: Spacing.sm),
+ ]
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ CardPageContent(spacing: Spacing.xl) {
+ if !config.clipboardHistoryEnabled {
+ clipboardHistoryBanner
+ }
+ enabledSection
+ if !store.availableSkills.isEmpty {
+ availableSection
+ }
+ }
+ .tabBarScrollBottomPadding()
+ }
+ .background(palette.background)
+ .navigationTitle("skills.title")
+ .navigationBarTitleDisplayMode(.large)
+ }
+ .sheet(item: $viewingSkill) { skill in
+ SkillDetailSheet(
+ store: store,
+ skill: skill,
+ onDismiss: { viewingSkill = nil },
+ onAddOrWarn: addOrWarn,
+ onConfirmInstall: confirmShortcutInstall
+ )
+ }
+ .alert(
+ AppL10n.string("skills.full.title", language: config.uiLanguage),
+ isPresented: $showFullAlert
+ ) {
+ Button("common.done") { showFullAlert = false }
+ } message: {
+ Text(AppL10n.string("skills.full.message", language: config.uiLanguage))
+ }
+ .onAppear { store.reload() }
+ }
+
+ private var clipboardHistoryBanner: some View {
+ Text("skills.clipboardHistory.banner")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textSecondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(Spacing.md)
+ .background(palette.surface, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(palette.divider, lineWidth: 0.5)
+ )
+ }
+
+ private var enabledSection: some View {
+ CardSection(
+ title: AppL10n.format(
+ "skills.enabled.section",
+ language: config.uiLanguage,
+ store.enabledSkills.count,
+ AIAgentSkillLayout.maximumEnabled
+ )
+ ) {
+ if store.enabledSkills.isEmpty {
+ Text("skills.enabled.empty")
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textTertiary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ } else {
+ LazyVGrid(columns: columns, spacing: Spacing.sm) {
+ ForEach(store.enabledSkills) { skill in
+ skillCard(skill, showsEnabledBadge: true)
+ .onTapGesture { viewingSkill = skill }
+ .draggable(skill.id) {
+ skillCard(skill, showsEnabledBadge: true)
+ .frame(width: 160)
+ }
+ .dropDestination(for: String.self) { items, _ in
+ guard let dragged = items.first else { return false }
+ store.moveEnabled(id: dragged, onto: skill.id)
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private var availableSection: some View {
+ CardSection("skills.available.section") {
+ LazyVGrid(columns: columns, spacing: Spacing.sm) {
+ ForEach(store.availableSkills) { skill in
+ Button {
+ viewingSkill = skill
+ } label: {
+ skillCard(skill, showsEnabledBadge: false)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ private func skillCard(_ skill: AIClipboardSkill, showsEnabledBadge: Bool) -> some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ HStack(spacing: Spacing.sm) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ Spacer(minLength: 0)
+ if showsEnabledBadge {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 18, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ }
+ }
+ Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage))
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textPrimary)
+ .lineLimit(1)
+ Text(AppL10n.string(skill.descriptionKey, language: config.uiLanguage))
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textTertiary)
+ .lineLimit(2)
+ Spacer(minLength: 0)
+ }
+ .frame(maxWidth: .infinity, minHeight: 96, alignment: .leading)
+ .padding(Spacing.md)
+ .background(
+ showsEnabledBadge ? palette.accentMuted : palette.surface,
+ in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ )
+ .overlay(
+ RoundedRectangle(cornerRadius: Radius.xl, style: .continuous)
+ .stroke(
+ showsEnabledBadge ? palette.accent : palette.divider,
+ lineWidth: showsEnabledBadge ? 1.5 : 0.5
+ )
+ )
+ .clipShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ .contentShape(RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
+ }
+
+ private func addOrWarn(_ skill: AIClipboardSkill) {
+ switch store.enable(skill.id) {
+ case .enabled, .alreadyEnabled:
+ viewingSkill = nil
+ case .full:
+ showFullAlert = true
+ case .needsShortcut, .unknown:
+ break
+ }
+ }
+
+ private func confirmShortcutInstall(_ skill: AIClipboardSkill) {
+ switch store.confirmShortcutAndEnable(skill.id) {
+ case .enabled, .alreadyEnabled:
+ viewingSkill = nil
+ case .full:
+ showFullAlert = true
+ case .needsShortcut, .unknown:
+ break
+ }
+ }
+}
+
+private struct SkillDetailSheet: View {
+ @Environment(\.themePalette) private var palette
+ @ObservedObject var store: AIAgentSkillLayoutStore
+ @ObservedObject private var config = ProviderConfig.shared
+
+ let skill: AIClipboardSkill
+ let onDismiss: () -> Void
+ let onAddOrWarn: (AIClipboardSkill) -> Void
+ let onConfirmInstall: (AIClipboardSkill) -> Void
+
+ /// Content stack height; nav chrome is added for the detent.
+ @State private var contentHeight: CGFloat = 240
+ private let navigationChrome: CGFloat = 56
+
+ var body: some View {
+ NavigationStack {
+ CardPageContent(
+ spacing: Spacing.md,
+ topPadding: Spacing.md,
+ bottomPadding: Spacing.lg
+ ) {
+ header
+ explanation
+ skillActions
+ }
+ .background(palette.background)
+ .fixedSize(horizontal: false, vertical: true)
+ .onGeometryChange(for: CGFloat.self) { proxy in
+ proxy.size.height
+ } action: { newHeight in
+ let next = newHeight + navigationChrome
+ if abs(contentHeight - next) > 1 {
+ contentHeight = next
+ }
+ }
+ .navigationTitle("skills.detail.title")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("common.done", action: onDismiss)
+ }
+ }
+ }
+ .presentationDetents([.height(contentHeight)])
+ .presentationDragIndicator(.visible)
+ .presentationContentInteraction(.resizes)
+ .animation(.easeInOut(duration: 0.2), value: contentHeight)
+ }
+
+ private var explanation: some View {
+ Text(AppL10n.string(explanationKey, language: config.uiLanguage))
+ .font(TypeStyle.body)
+ .foregroundStyle(palette.textSecondary)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, minHeight: 80, maxHeight: 80, alignment: .topLeading)
+ }
+
+ private var explanationKey: String {
+ if skill.requiresShortcut, !store.layout.hasConfirmedShortcut(skill.id) {
+ return "skills.install.lead"
+ }
+ if skill.requiresShortcut, store.layout.isEnabled(skill.id) {
+ return "skills.action.turnOffHint"
+ }
+ return skill.descriptionKey
+ }
+
+ private var header: some View {
+ HStack(alignment: .center, spacing: Spacing.md) {
+ Image(systemName: skill.systemImage)
+ .font(.system(size: 16, weight: .semibold))
+ .foregroundStyle(palette.accent)
+ .frame(width: 36, height: 36)
+ .background(palette.accentMuted, in: Circle())
+ VStack(alignment: .leading, spacing: 2) {
+ Text(AppL10n.string(skill.cardTitleKey, language: config.uiLanguage))
+ .font(TypeStyle.title3)
+ .foregroundStyle(palette.textPrimary)
+ if skill.isDefault {
+ Text("skills.badge.default")
+ .font(TypeStyle.caption2)
+ .foregroundStyle(palette.textSecondary)
+ }
+ }
+ Spacer(minLength: 0)
+ }
+ }
+
+ @ViewBuilder
+ private var skillActions: some View {
+ let enabled = store.layout.isEnabled(skill.id)
+ if skill.requiresShortcut {
+ if !store.layout.hasConfirmedShortcut(skill.id) {
+ shortcutInstallBlock
+ } else {
+ if enabled {
+ fullWidthButton("skills.action.turnOff", prominent: false) {
+ store.disable(skill.id)
+ onDismiss()
+ }
+ } else {
+ fullWidthButton("skills.action.addToKeyboard", prominent: true) {
+ onAddOrWarn(skill)
+ }
+ }
+ fullWidthButton("skills.action.reinstallShortcut", prominent: false) {
+ openShortcutInstall()
+ }
+ }
+ } else if enabled {
+ fullWidthButton("skills.action.turnOff", prominent: false) {
+ store.disable(skill.id)
+ onDismiss()
+ }
+ } else {
+ fullWidthButton("skills.action.addToKeyboard", prominent: true) {
+ onAddOrWarn(skill)
+ }
+ }
+ }
+
+ private var shortcutInstallBlock: some View {
+ VStack(alignment: .leading, spacing: Spacing.sm) {
+ fullWidthButton("skills.install.openShortcuts", prominent: true) {
+ openShortcutInstall()
+ }
+ fullWidthButton("skills.install.confirmAdded", prominent: false) {
+ onConfirmInstall(skill)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func fullWidthButton(
+ _ titleKey: LocalizedStringKey,
+ prominent: Bool,
+ action: @escaping () -> Void
+ ) -> some View {
+ let label = Text(titleKey)
+ .frame(maxWidth: .infinity, minHeight: 38)
+ if prominent {
+ Button(action: action) { label }
+ .buttonStyle(.borderedProminent)
+ .tint(palette.accent)
+ } else {
+ Button(action: action) { label }
+ .buttonStyle(.bordered)
+ }
+ }
+
+ private func openShortcutInstall() {
+ AIAgentShortcutInstaller.openInstallPage(for: skill)
+ }
+}
diff --git a/OSGKeyboard/Views/Components/MinimalTabBar.swift b/OSGKeyboard/Views/Components/MinimalTabBar.swift
index 91ce86e..5b72805 100644
--- a/OSGKeyboard/Views/Components/MinimalTabBar.swift
+++ b/OSGKeyboard/Views/Components/MinimalTabBar.swift
@@ -1,7 +1,7 @@
// MinimalTabBar.swift
// OSGKeyboard · Main App
//
-// Bottom tab bar — icon + label for Home / Styles / Settings.
+// Bottom tab bar — icon + label for Home / Skills / Styles / Settings.
// The dock capsule is iOS 26 Liquid Glass; the selected tab is a green
// fill inside that capsule (Photos-style), not a second glass layer.
// History + dictionary live as Home cards (not dock tabs).
@@ -11,13 +11,14 @@ import OSGKeyboardShared
enum AppTab: Int, CaseIterable {
case keyboard
+ case skills
case styles
case settings
var icon: MaterialIconName {
switch self {
case .keyboard: return .keyboard
- case .styles: return .menuBook // unused — styles uses SF Symbol
+ case .skills, .styles: return .menuBook // unused — these tabs use SF Symbols
case .settings: return .settings
}
}
@@ -25,6 +26,7 @@ enum AppTab: Int, CaseIterable {
/// SF Symbol overrides shared with the Mac and iPad sidebars.
var sfSymbol: String? {
switch self {
+ case .skills: return "sparkles"
case .styles: return "text.badge.star"
default: return nil
}
@@ -33,6 +35,7 @@ enum AppTab: Int, CaseIterable {
var accessibilityKey: LocalizedStringKey {
switch self {
case .keyboard: return "tab.keyboard"
+ case .skills: return "tab.skills"
case .styles: return "tab.styles"
case .settings: return "tab.settings"
}
@@ -44,6 +47,7 @@ enum AppTab: Int, CaseIterable {
var sidebarSystemImage: String {
switch self {
case .keyboard: return "house"
+ case .skills: return "sparkles"
case .styles: return "text.badge.star"
case .settings: return "gearshape"
}
@@ -110,7 +114,7 @@ struct MinimalTabBar: View {
.padding(.horizontal, TabBarDockMetrics.dockInsetHorizontal)
.padding(.vertical, TabBarDockMetrics.dockInsetVertical)
.glassEffect(.regular.interactive(), in: .capsule)
- .frame(maxWidth: 280)
+ .frame(maxWidth: 360)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, TabBarDockMetrics.bottomPadding)
}
diff --git a/OSGKeyboard/Views/MainAppRoot.swift b/OSGKeyboard/Views/MainAppRoot.swift
index 8f97a6b..45f46cf 100644
--- a/OSGKeyboard/Views/MainAppRoot.swift
+++ b/OSGKeyboard/Views/MainAppRoot.swift
@@ -214,6 +214,12 @@ struct MainAppRoot: View {
switch url.host {
case "startflow":
flowManager.startSession(coldStart: true, reason: "url.startflow")
+ case "skill":
+ if url.path.contains("shortcut-result") {
+ AIAgentShortcutRunner.logShortcutCallback(url)
+ } else if url.path.contains("run") {
+ AIAgentShortcutRunner.runPendingIfNeeded()
+ }
case "deployrime":
// The keyboard sends the user here precisely because typing
// resources are missing — deploy without waiting for warmup.
diff --git a/OSGKeyboard/Views/MainTabContent.swift b/OSGKeyboard/Views/MainTabContent.swift
index 9a3b3de..df1014e 100644
--- a/OSGKeyboard/Views/MainTabContent.swift
+++ b/OSGKeyboard/Views/MainTabContent.swift
@@ -14,6 +14,8 @@ struct MainTabContent: View {
switch tab {
case .keyboard:
HomeView()
+ case .skills:
+ AIAgentSkillsView()
case .styles:
PolishStylesView()
case .settings:
diff --git a/OSGKeyboard/en.lproj/Localizable.strings b/OSGKeyboard/en.lproj/Localizable.strings
index df445f2..efaeca5 100644
--- a/OSGKeyboard/en.lproj/Localizable.strings
+++ b/OSGKeyboard/en.lproj/Localizable.strings
@@ -427,8 +427,35 @@
"tab.history" = "History";
"tab.dictionary" = "Dictionary";
"tab.styles" = "Styles";
+"tab.skills" = "Skills";
"tab.settings" = "Settings";
+/* AI Agent skills */
+"skills.title" = "Skills";
+"skills.enabled.section" = "In use (%d/%d)";
+"skills.enabled.empty" = "No skills on the keyboard. Turn one on from the list below.";
+"skills.available.section" = "Available";
+"skills.clipboardHistory.banner" = "Turn on Clipboard History in Settings to use these skills after you copy text.";
+"skills.detail.title" = "Skill";
+"skills.badge.default" = "Default skill";
+"skills.action.turnOff" = "Turn off";
+"skills.action.turnOffHint" = "Removes it from the keyboard only.";
+"skills.action.addToKeyboard" = "Add to keyboard";
+"skills.action.reinstallShortcut" = "Reinstall Shortcut";
+"skills.full.title" = "Skill slots full";
+"skills.full.message" = "You can enable up to 8 skills. Turn one off, then add this skill from its card.";
+"skills.reply.name" = "Reply";
+"skills.reply.description" = "Draft a polite reply from the copied text, ready to insert.";
+"skills.summarize.name" = "Summarize";
+"skills.summarize.description" = "Keep the key facts and conclusions, without rewriting it as a chat message.";
+"skills.translate.name" = "Translate";
+"skills.translate.description" = "Translate the copied text using your keyboard language setting.";
+"skills.extractTodos.name" = "Extract tasks";
+"skills.extractTodos.description" = "Extract to-dos from copied text into Reminders.";
+"skills.install.lead" = "Add it, then confirm. Don’t rename it.";
+"skills.install.openShortcuts" = "Add Shortcut";
+"skills.install.confirmAdded" = "I've added it";
+
/* Polish style packs */
"polishStyles.title" = "Polish styles";
"polishStyles.add" = "Add style";
diff --git a/OSGKeyboard/zh-Hans.lproj/Localizable.strings b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
index f41e7da..891f7b8 100644
--- a/OSGKeyboard/zh-Hans.lproj/Localizable.strings
+++ b/OSGKeyboard/zh-Hans.lproj/Localizable.strings
@@ -426,8 +426,35 @@
"tab.history" = "历史";
"tab.dictionary" = "词库";
"tab.styles" = "风格";
+"tab.skills" = "技能";
"tab.settings" = "设置";
+/* AI Agent 技能 */
+"skills.title" = "技能";
+"skills.enabled.section" = "使用中(%d/%d)";
+"skills.enabled.empty" = "键盘上还没有技能。从下方列表打开一个即可。";
+"skills.available.section" = "可添加";
+"skills.clipboardHistory.banner" = "请先在设置中打开剪贴板历史,复制文字后才能在键盘上使用这些技能。";
+"skills.detail.title" = "技能";
+"skills.badge.default" = "默认技能";
+"skills.action.turnOff" = "关闭";
+"skills.action.turnOffHint" = "只从键盘拿掉,不会删除捷径。";
+"skills.action.addToKeyboard" = "添加到键盘";
+"skills.action.reinstallShortcut" = "重新安装捷径";
+"skills.full.title" = "技能已满";
+"skills.full.message" = "最多同时启用 8 个技能。请先关闭一个,再从卡片手动添加。";
+"skills.reply.name" = "回复";
+"skills.reply.description" = "根据复制的内容起草一段礼貌回复,可插入当前输入框。";
+"skills.summarize.name" = "总结";
+"skills.summarize.description" = "保留关键事实与结论,不会改写成可发送的短消息。";
+"skills.translate.name" = "翻译";
+"skills.translate.description" = "按键盘目标语言翻译复制的内容。";
+"skills.extractTodos.name" = "提取待办";
+"skills.extractTodos.description" = "从复制内容提取待办,写入提醒事项。";
+"skills.install.lead" = "添加后点「我已添加」,请勿改名。";
+"skills.install.openShortcuts" = "添加捷径";
+"skills.install.confirmAdded" = "我已添加";
+
/* 润色风格包 */
"polishStyles.title" = "润色风格";
"polishStyles.add" = "添加风格";
diff --git a/OSGKeyboardExt/KeyboardViewController.swift b/OSGKeyboardExt/KeyboardViewController.swift
index 42f608b..9d13838 100644
--- a/OSGKeyboardExt/KeyboardViewController.swift
+++ b/OSGKeyboardExt/KeyboardViewController.swift
@@ -520,6 +520,11 @@ public final class KeyboardViewController: UIInputViewController {
state.submitAIClipboardSkill = { [weak self] skill in
self?.aiKeyboardCoordinator.submitClipboardSkill(skill)
}
+ state.runClipboardExportSkill = { [weak self] skillID, titles in
+ AppGroupStore().setPendingShortcutRun(skillID: skillID, titles: titles)
+ AIAgentShortcutRun.trace("keyboard.openHost osgkeyboard://skill/run")
+ self?.openSkillShortcutRun()
+ }
state.openSettings = { [weak self] in self?.openHostApp() }
state.openInputMethodSetup = { [weak self] in self?.openHostApp(path: "deployrime") }
state.openClipboardSettings = { [weak self] in
@@ -942,6 +947,19 @@ public final class KeyboardViewController: UIInputViewController {
// MARK: - Open host app
+ private func openSkillShortcutRun() {
+ guard hasFullAccess else {
+ state.skillTipText = ExtL10n.string("keyboard.error.fullAccessForJump")
+ return
+ }
+ guard let url = URL(string: "osgkeyboard://skill/run") else { return }
+ HostAppLauncher.open(url: url, from: self) { [weak self] success in
+ AIAgentShortcutRun.trace("keyboard.openHost result success=\(success)")
+ if success { return }
+ self?.state.skillTipText = ExtL10n.string("keyboard.ai.skill.handoffFailed")
+ }
+ }
+
private func openHostApp(path: String = "settings") {
guard hasFullAccess else {
let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
diff --git a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
index ddaa662..6cbe42f 100644
--- a/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
+++ b/OSGKeyboardExt/Services/AIKeyboardCoordinator.swift
@@ -70,15 +70,26 @@ final class AIKeyboardCoordinator {
func submitClipboardSkill(_ skill: AIClipboardSkill) {
guard canAcceptIdleSubmit else { return }
enterIfNeeded()
+ state.pendingClipboardSkillID = skill.kind == .export ? skill.id : nil
let instruction = AIClipboardSkillCatalog.instruction(
for: skill,
locale: AIHintLocaleResolver.packLocale(),
translationTargetLocaleId: state.translationTargetLocaleId
)
+ let material = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
+ AIAgentShortcutRun.trace("keyboard.submit skill=\(skill.id) kind=\(skill.kind)")
+ if let material {
+ AIAgentShortcutRun.traceBody("keyboard.clipboard", material)
+ } else {
+ AIAgentShortcutRun.trace("keyboard.clipboard missing")
+ }
let resolution = AIClipboardPrompt.resolve(
instruction: instruction,
- material: ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
+ material: material
)
+ if case .materialUnavailable = resolution {
+ AIAgentShortcutRun.trace("keyboard.submit rejected clipboardUnavailable skill=\(skill.id)")
+ }
submitResolvedPrompt(resolution)
}
@@ -106,24 +117,30 @@ final class AIKeyboardCoordinator {
private func submitResolvedPrompt(_ resolution: AIClipboardPrompt.Resolution) {
guard case .ready(let prompt) = resolution else {
// The clipboard window closed between rendering and this tap.
+ state.pendingClipboardSkillID = nil
state.aiSession.fail(
ExtL10n.string("keyboard.ai.error.clipboardUnavailable"),
utteranceID: nil
)
return
}
- guard let conversationID = state.aiSession.conversationID else { return }
+ guard let conversationID = state.aiSession.conversationID else {
+ state.pendingClipboardSkillID = nil
+ return
+ }
let disposition = flow.submitAIQuestion(
text: prompt,
conversationID: conversationID
)
if case .rejected(let rejection) = disposition {
+ state.pendingClipboardSkillID = nil
state.aiSession.fail(message(for: rejection), utteranceID: nil)
}
}
func cancel() {
guard state.aiSession.isBusy else { return }
+ state.pendingClipboardSkillID = nil
flow.cancelAIRecording()
state.aiSession.cancelCurrentWork()
}
@@ -181,6 +198,7 @@ final class AIKeyboardCoordinator {
}
func receivePartialAnswer(_ draft: String, utteranceID: UUID) {
+ if isPendingExportSkill { return }
state.aiSession.receivePartialAnswer(draft, utteranceID: utteranceID)
}
@@ -188,6 +206,11 @@ final class AIKeyboardCoordinator {
guard result.resolvedUtteranceMode == .aiQuestion else {
return
}
+ if isPendingExportSkill {
+ guard result.aiConversationID == state.aiSession.conversationID else { return }
+ finishExportSkill(answer: result.text ?? "")
+ return
+ }
guard result.aiConversationID == state.aiSession.conversationID,
let answer = result.text,
!answer.isEmpty else {
@@ -201,14 +224,59 @@ final class AIKeyboardCoordinator {
}
func fail(_ message: String, utteranceID: UUID?) {
+ state.pendingClipboardSkillID = nil
state.aiSession.fail(message, utteranceID: utteranceID)
}
private func endConversationIfNeeded() {
+ state.pendingClipboardSkillID = nil
guard let conversationID = state.aiSession.conversationID else { return }
flow.endAIConversation(conversationID)
}
+ private var isPendingExportSkill: Bool {
+ guard let id = state.pendingClipboardSkillID else { return false }
+ return AIClipboardSkillCatalog.skill(id: id)?.kind == .export
+ }
+
+ /// Parse extract-todos. Empty → in-keyboard tip, stay in the host app.
+ /// Titles → hand off to the host to run the companion Shortcut.
+ private func finishExportSkill(answer: String) {
+ let source = ClipboardHistoryStore.shared.newestAIHintEligibleEntry()?.text
+ let items = AITodoExtraction.items(from: answer, sourceClipboard: source)
+ AIAgentShortcutRun.traceBody("keyboard.llmRaw", answer)
+ AIAgentShortcutRun.trace(
+ "keyboard.parse items=\(items.count) clipboardChars=\(source?.count ?? 0)"
+ )
+ #if DEBUG
+ if !items.isEmpty {
+ AIAgentShortcutRun.traceBody("keyboard.parsedTitles", items.joined(separator: "\n"))
+ }
+ #endif
+ let skillID = state.pendingClipboardSkillID
+ state.pendingClipboardSkillID = nil
+ if state.aiSession.isBusy {
+ state.aiSession.cancelCurrentWork()
+ }
+ if state.aiSession.isActive {
+ state.aiSession.resetConversationPreservingAnswer()
+ }
+ guard !items.isEmpty else {
+ AIAgentShortcutRun.trace("keyboard.parse empty — skip Shortcuts")
+ state.skillTipText = ExtL10n.string("keyboard.ai.skill.noTodos")
+ return
+ }
+ guard let skillID,
+ AIClipboardSkillCatalog.skill(id: skillID)?.shortcutName != nil else {
+ AIAgentShortcutRun.trace("keyboard.parse missingShortcut skill=\(skillID ?? "nil")")
+ state.skillTipText = ExtL10n.string("keyboard.ai.skill.shortcutMissing")
+ return
+ }
+ AIAgentShortcutRun.trace("keyboard.handoffToHost skill=\(skillID) items=\(items.count)")
+ state.skillTipText = ExtL10n.string("keyboard.ai.skill.runningShortcut")
+ state.runClipboardExportSkill(skillID, items)
+ }
+
private func message(for rejection: FlowUtteranceStartRejection) -> String {
switch rejection {
case .onboardingIncomplete:
diff --git a/OSGKeyboardExt/Services/AppGroupPersistor.swift b/OSGKeyboardExt/Services/AppGroupPersistor.swift
index f66561e..a3df9d4 100644
--- a/OSGKeyboardExt/Services/AppGroupPersistor.swift
+++ b/OSGKeyboardExt/Services/AppGroupPersistor.swift
@@ -43,6 +43,7 @@ public struct AppGroupPersistor {
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
+ state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
@@ -98,6 +99,7 @@ public struct AppGroupPersistor {
state.cursorDragNavigationEnabled = store.cursorDragNavigationEnabled
state.clipboardHistoryEnabled = store.clipboardHistoryEnabled
state.clipboardCandidateBarEnabled = store.clipboardCandidateBarEnabled
+ state.enabledClipboardSkillIDs = store.agentSkillLayout.enabledIDs
state.keyboardHapticIntensity = store.keyboardHapticIntensity
applyAPIKeyAvailability(store: store, into: state)
}
diff --git a/OSGKeyboardExt/Views/AIKeyboardView.swift b/OSGKeyboardExt/Views/AIKeyboardView.swift
index fb43f32..16bb6e9 100644
--- a/OSGKeyboardExt/Views/AIKeyboardView.swift
+++ b/OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -55,6 +55,17 @@ struct AIKeyboardView: View {
}
.onChange(of: state.clipboardHistoryEnabled) { _, _ in resetCarousel() }
.onChange(of: clipboardHistory.entries.first?.id) { _, _ in resetCarousel() }
+ .onChange(of: state.enabledClipboardSkillIDs) { _, _ in resetCarousel() }
+ .onChange(of: state.skillTipText) { _, tip in
+ guard let tip, !tip.isEmpty else { return }
+ Task { @MainActor in
+ try? await Task.sleep(nanoseconds: 2_800_000_000)
+ if state.skillTipText == tip {
+ state.skillTipText = nil
+ }
+ }
+ }
+ .animation(Motion.soft, value: state.skillTipText)
.onReceive(
Timer.publish(every: Layout.carouselInterval, on: .main, in: .common).autoconnect()
) { _ in
@@ -172,6 +183,19 @@ struct AIKeyboardView: View {
statusLine
.frame(height: Layout.statusHeight)
.padding(.horizontal, Spacing.md)
+
+ if let tip = state.skillTipText, !tip.isEmpty {
+ Text(tip)
+ .font(TypeStyle.caption)
+ .foregroundStyle(palette.textPrimary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 8)
+ .glassEffect(.regular, in: Capsule())
+ .padding(.bottom, Layout.statusHeight + Spacing.sm)
+ .transition(.opacity)
+ .allowsHitTesting(false)
+ }
}
}
@@ -212,8 +236,9 @@ struct AIKeyboardView: View {
}
private var clipboardSkillRow: some View {
- HStack(spacing: Spacing.lg) {
- ForEach(AIClipboardSkillCatalog.visible()) { skill in
+ let skills = visibleClipboardSkills
+ let row = HStack(spacing: Spacing.lg) {
+ ForEach(skills) { skill in
Button {
state.submitAIClipboardSkill(skill)
} label: {
@@ -233,6 +258,15 @@ struct AIKeyboardView: View {
.accessibilityLabel(Text(clipboardSkillTitle(skill)))
}
}
+ return Group {
+ if skills.count > 4 {
+ ScrollView(.horizontal, showsIndicators: false) {
+ row.padding(.horizontal, Spacing.md)
+ }
+ } else {
+ row
+ }
+ }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -250,12 +284,17 @@ struct AIKeyboardView: View {
/// Copy-then-30s window: skill chips replace the rotating hint.
private var showsClipboardSkills: Bool {
guard showsPlaceholder, state.clipboardHistoryEnabled else { return false }
+ guard !visibleClipboardSkills.isEmpty else { return false }
return AIHintPool.isClipboardSkillWindowActive(
clipboardHistoryEnabled: true,
newestClipboard: clipboardHistory.newestEntry
)
}
+ private var visibleClipboardSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.visible(enabledIDs: state.enabledClipboardSkillIDs)
+ }
+
/// No draft/answer yet — show the centered hint carousel instead of a scroll body.
private var showsPlaceholder: Bool {
let hasDraft = !(state.aiSession.draftAnswerText?.isEmpty ?? true)
@@ -432,6 +471,9 @@ struct AIKeyboardView: View {
}
return state.aiSession.transcript
case .generating:
+ if state.pendingClipboardSkillID == AIClipboardSkillCatalog.extractTodosID {
+ return ExtL10n.string("keyboard.ai.skill.extracting")
+ }
if let draft = state.aiSession.draftAnswerText, !draft.isEmpty {
return ExtL10n.string("keyboard.ai.generating")
}
diff --git a/OSGKeyboardExt/en.lproj/Keyboard.strings b/OSGKeyboardExt/en.lproj/Keyboard.strings
index 39367d3..46f10dc 100644
--- a/OSGKeyboardExt/en.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/en.lproj/Keyboard.strings
@@ -297,3 +297,9 @@
"keyboard.ai.skill.reply" = "Reply";
"keyboard.ai.skill.summarize" = "Summarize";
"keyboard.ai.skill.translate" = "Translate";
+"keyboard.ai.skill.extractTodos" = "Tasks";
+"keyboard.ai.skill.noTodos" = "No tasks in the clipboard";
+"keyboard.ai.skill.extracting" = "Finding tasks…";
+"keyboard.ai.skill.runningShortcut" = "Running Shortcut…";
+"keyboard.ai.skill.shortcutMissing" = "Companion Shortcut not found. Reinstall it in Skills";
+"keyboard.ai.skill.handoffFailed" = "Couldn’t open the app to run the Shortcut";
diff --git a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
index b16546a..3280535 100644
--- a/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
+++ b/OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings
@@ -297,3 +297,9 @@
"keyboard.ai.skill.reply" = "回复";
"keyboard.ai.skill.summarize" = "总结";
"keyboard.ai.skill.translate" = "翻译";
+"keyboard.ai.skill.extractTodos" = "待办";
+"keyboard.ai.skill.noTodos" = "剪贴板内容没有待办事项";
+"keyboard.ai.skill.extracting" = "正在提取待办…";
+"keyboard.ai.skill.runningShortcut" = "正在运行捷径…";
+"keyboard.ai.skill.shortcutMissing" = "找不到配套捷径,请在技能页重新安装";
+"keyboard.ai.skill.handoffFailed" = "无法打开 App 运行捷径";
diff --git a/OSGKeyboardShared/Models/AIAgentSkillLayout.swift b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift
new file mode 100644
index 0000000..2194185
--- /dev/null
+++ b/OSGKeyboardShared/Models/AIAgentSkillLayout.swift
@@ -0,0 +1,78 @@
+// AIAgentSkillLayout.swift
+// OSGKeyboard · Shared
+//
+// Enabled clipboard-skill slots (max 8, ordered) plus which export skills
+// the user confirmed a companion Shortcut for. Missing storage hydrates
+// to the three default transform skills; an explicit empty list is kept
+// so turning every skill off is distinct from a fresh install.
+
+import Foundation
+
+public struct AIAgentSkillLayout: Codable, Equatable, Sendable {
+ public static let maximumEnabled = 8
+ public static let defaultEnabledIDs = [
+ AIClipboardSkillCatalog.replyID,
+ AIClipboardSkillCatalog.summarizeID,
+ AIClipboardSkillCatalog.translateID,
+ ]
+
+ /// Keyboard chip order. Unknown / unconfirmed IDs are dropped on sanitize.
+ public var enabledIDs: [String]
+ /// Export skills whose companion Shortcut the user marked as added.
+ public var confirmedShortcutIDs: [String]
+
+ public static let `default` = AIAgentSkillLayout(
+ enabledIDs: defaultEnabledIDs,
+ confirmedShortcutIDs: []
+ )
+
+ public init(enabledIDs: [String], confirmedShortcutIDs: [String]) {
+ self.enabledIDs = enabledIDs
+ self.confirmedShortcutIDs = confirmedShortcutIDs
+ }
+
+ public var isFull: Bool {
+ enabledIDs.count >= Self.maximumEnabled
+ }
+
+ public func isEnabled(_ id: String) -> Bool {
+ enabledIDs.contains(id)
+ }
+
+ public func hasConfirmedShortcut(_ id: String) -> Bool {
+ confirmedShortcutIDs.contains(id)
+ }
+
+ /// Drops unknown IDs, unconfirmed export skills, and duplicates; caps at 8.
+ public func sanitized() -> AIAgentSkillLayout {
+ let known = Dictionary(uniqueKeysWithValues: AIClipboardSkillCatalog.catalog.map { ($0.id, $0) })
+ var seenEnabled = Set()
+ let enabled = enabledIDs.filter { id in
+ guard let skill = known[id], seenEnabled.insert(id).inserted else { return false }
+ if skill.requiresShortcut {
+ return confirmedShortcutIDs.contains(id)
+ }
+ return true
+ }
+ .prefix(Self.maximumEnabled)
+
+ var seenConfirmed = Set()
+ let confirmed = confirmedShortcutIDs.filter { id in
+ guard known[id]?.requiresShortcut == true else { return false }
+ return seenConfirmed.insert(id).inserted
+ }
+
+ return AIAgentSkillLayout(
+ enabledIDs: Array(enabled),
+ confirmedShortcutIDs: confirmed
+ )
+ }
+}
+
+public enum AIAgentSkillEnableResult: Equatable, Sendable {
+ case enabled
+ case alreadyEnabled
+ case needsShortcut
+ case full
+ case unknown
+}
diff --git a/OSGKeyboardShared/Models/AppGroupConfiguration.swift b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
index 5e63e66..044dd28 100644
--- a/OSGKeyboardShared/Models/AppGroupConfiguration.swift
+++ b/OSGKeyboardShared/Models/AppGroupConfiguration.swift
@@ -69,6 +69,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
"config.flowInactivityDuration.migratedToFiveMinuteDefault"
/// Diagnostic switch: when false, local ASR skips the custom language model.
public static let localASRCustomLanguageModelEnabled = "config.localASR.customLanguageModelEnabled"
+ /// Enabled AI Agent skill IDs (order) + confirmed companion Shortcuts.
+ public static let agentSkillLayout = "config.aiAgentSkills.layout.v1"
}
// MARK: - Stored fields
@@ -113,8 +115,8 @@ public struct AppGroupConfiguration: Sendable, Equatable {
public var flowSkipAppSwitch: Bool
/// Idle timeout before the Flow session ends; resets on each utterance.
public var flowInactivityDuration: FlowInactivityDuration
- /// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
- public var localASRCustomLanguageModelEnabled: Bool
+ /// Whether local `SpeechAnalyzer` should attach the prepared custom language model.
+ public var localASRCustomLanguageModelEnabled: Bool
// MARK: - Derived
diff --git a/OSGKeyboardShared/Services/AIAgentShortcutRun.swift b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift
new file mode 100644
index 0000000..206e4c8
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAgentShortcutRun.swift
@@ -0,0 +1,128 @@
+// AIAgentShortcutRun.swift
+// OSGKeyboard · Shared
+//
+// Ephemeral App Group payload for one keyboard → host → Shortcuts hop.
+// The keyboard writes titles here, then opens `osgkeyboard://skill/run`.
+// The host consumes the payload (once) and opens the Shortcuts URL.
+
+import Foundation
+
+public struct AIAgentShortcutRunPayload: Codable, Equatable, Sendable {
+ public let skillID: String
+ public let titles: [String]
+ public let createdAt: Date
+
+ public init(skillID: String, titles: [String], createdAt: Date = Date()) {
+ self.skillID = skillID
+ self.titles = titles
+ self.createdAt = createdAt
+ }
+
+ public var joinedTitles: String {
+ titles.joined(separator: "\n")
+ }
+}
+
+public enum AIAgentShortcutRun {
+ public static let pendingKey = "config.aiAgentSkills.pendingRun.v1"
+ /// Drop payloads older than this; a leftover write must not fire later.
+ public static let payloadTTL: TimeInterval = 60
+
+ public static func shortcutsRunURL(
+ name: String,
+ text: String,
+ xSuccess: String? = nil,
+ xError: String? = nil,
+ xCancel: String? = nil
+ ) -> URL? {
+ var components = URLComponents()
+ components.scheme = "shortcuts"
+ let usesCallback = xSuccess != nil || xError != nil || xCancel != nil
+ if usesCallback {
+ components.host = "x-callback-url"
+ components.path = "/run-shortcut"
+ } else {
+ components.host = "run-shortcut"
+ }
+ var items = [
+ URLQueryItem(name: "name", value: name),
+ URLQueryItem(name: "input", value: "text"),
+ URLQueryItem(name: "text", value: text),
+ ]
+ if let xSuccess {
+ items.append(URLQueryItem(name: "x-success", value: xSuccess))
+ }
+ if let xError {
+ items.append(URLQueryItem(name: "x-error", value: xError))
+ }
+ if let xCancel {
+ items.append(URLQueryItem(name: "x-cancel", value: xCancel))
+ }
+ components.queryItems = items
+ return components.url
+ }
+
+ /// Xcode / Console search: `OSGDiag/skills`. DEBUG builds include bodies.
+ public static func trace(_ message: String) {
+ OSGDiag.log(message, category: "skills")
+ }
+
+ /// Single-line preview so Console keeps the format (`\\n` for newlines).
+ public static func preview(_ text: String, limit: Int = 1200) -> String {
+ let escaped = text
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\r\n", with: "\\n")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ .replacingOccurrences(of: "\r", with: "\\n")
+ .replacingOccurrences(of: "\t", with: "\\t")
+ if escaped.count <= limit { return escaped }
+ return String(escaped.prefix(limit)) + "…(chars=\(text.count))"
+ }
+
+ public static func traceBody(_ label: String, _ text: String) {
+ #if DEBUG
+ trace("\(label) chars=\(text.count) body=\(preview(text))")
+ #else
+ trace("\(label) chars=\(text.count)")
+ #endif
+ }
+
+ /// Opens the Shortcuts Add sheet for an iCloud share token.
+ /// Do not `open` the HTTPS share page from the app — Universal Links
+ /// often land on Gallery and drop the token.
+ public static func shortcutsInstallURL(from shareURL: URL) -> URL? {
+ guard let token = iCloudShareToken(from: shareURL) else { return nil }
+ return URL(string: "shortcuts://shortcuts/\(token)")
+ }
+
+ public static func iCloudShareToken(from shareURL: URL) -> String? {
+ guard let host = shareURL.host, host.contains("icloud.com") else { return nil }
+ let parts = shareURL.path.split(separator: "/").map(String.init)
+ guard let index = parts.firstIndex(of: "shortcuts"),
+ parts.count > index + 1 else { return nil }
+ let token = parts[index + 1]
+ guard token != "api", !token.isEmpty else { return nil }
+ return token
+ }
+
+ public static func openShortcutURL(name: String) -> URL? {
+ var components = URLComponents()
+ components.scheme = "shortcuts"
+ components.host = "open-shortcut"
+ components.queryItems = [URLQueryItem(name: "name", value: name)]
+ return components.url
+ }
+
+ public static func encode(_ payload: AIAgentShortcutRunPayload) -> Data? {
+ try? JSONEncoder().encode(payload)
+ }
+
+ public static func decode(_ data: Data, now: Date = Date()) -> AIAgentShortcutRunPayload? {
+ guard let payload = try? JSONDecoder().decode(AIAgentShortcutRunPayload.self, from: data) else {
+ return nil
+ }
+ guard now.timeIntervalSince(payload.createdAt) <= payloadTTL else { return nil }
+ guard !payload.titles.isEmpty else { return nil }
+ return payload
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift
new file mode 100644
index 0000000..be6d884
--- /dev/null
+++ b/OSGKeyboardShared/Services/AIAgentSkillLayoutStore.swift
@@ -0,0 +1,110 @@
+// AIAgentSkillLayoutStore.swift
+// OSGKeyboard · Shared
+//
+// Observable facade over the persisted skill layout. The Skills tab mutates
+// this; the keyboard reads the same App Group snapshot on each config poll.
+
+import Foundation
+import Combine
+
+@MainActor
+public final class AIAgentSkillLayoutStore: ObservableObject {
+ public static let shared = AIAgentSkillLayoutStore()
+
+ @Published public private(set) var layout: AIAgentSkillLayout
+
+ private let defaults: UserDefaults?
+ private let persist: (AIAgentSkillLayout) -> Void
+ private let load: () -> AIAgentSkillLayout
+
+ public init(defaults: UserDefaults? = nil) {
+ if let defaults {
+ self.defaults = defaults
+ self.load = { AppGroupStore(defaults: defaults).agentSkillLayout }
+ self.persist = { AppGroupStore(defaults: defaults).setAgentSkillLayout($0) }
+ } else {
+ self.defaults = nil
+ self.load = { AppGroupStore().agentSkillLayout }
+ self.persist = { AppGroupStore().setAgentSkillLayout($0) }
+ }
+ self.layout = self.load()
+ }
+
+ public func reload() {
+ layout = load()
+ }
+
+ public var enabledSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.visible(enabledIDs: layout.enabledIDs)
+ }
+
+ public var availableSkills: [AIClipboardSkill] {
+ AIClipboardSkillCatalog.catalog.filter { !layout.isEnabled($0.id) }
+ }
+
+ @discardableResult
+ public func enable(_ id: String) -> AIAgentSkillEnableResult {
+ let current = layout.sanitized()
+ guard let skill = AIClipboardSkillCatalog.skill(id: id) else { return .unknown }
+ if current.isEnabled(id) { return .alreadyEnabled }
+ if skill.requiresShortcut, !current.hasConfirmedShortcut(id) {
+ return .needsShortcut
+ }
+ if current.isFull { return .full }
+ commit(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs + [id],
+ confirmedShortcutIDs: current.confirmedShortcutIDs
+ )
+ )
+ return .enabled
+ }
+
+ /// Drops the keyboard slot only. Companion Shortcuts stay installed;
+ /// the user deletes them in the Shortcuts app if they want them gone.
+ public func disable(_ id: String) {
+ let current = layout.sanitized()
+ commit(
+ AIAgentSkillLayout(
+ enabledIDs: current.enabledIDs.filter { $0 != id },
+ confirmedShortcutIDs: current.confirmedShortcutIDs
+ )
+ )
+ }
+
+ /// Marks the companion Shortcut as added, then tries to occupy a slot.
+ @discardableResult
+ public func confirmShortcutAndEnable(_ id: String) -> AIAgentSkillEnableResult {
+ guard let skill = AIClipboardSkillCatalog.skill(id: id), skill.requiresShortcut else {
+ return .unknown
+ }
+ var current = layout.sanitized()
+ if !current.confirmedShortcutIDs.contains(id) {
+ current.confirmedShortcutIDs.append(id)
+ }
+ commit(current)
+ return enable(id)
+ }
+
+ public func moveEnabled(id draggedID: String, onto targetID: String) {
+ var ids = layout.sanitized().enabledIDs
+ guard let from = ids.firstIndex(of: draggedID),
+ let to = ids.firstIndex(of: targetID),
+ from != to else { return }
+ ids.move(
+ fromOffsets: IndexSet(integer: from),
+ toOffset: to > from ? to + 1 : to
+ )
+ commit(
+ AIAgentSkillLayout(
+ enabledIDs: ids,
+ confirmedShortcutIDs: layout.confirmedShortcutIDs
+ )
+ )
+ }
+
+ private func commit(_ layout: AIAgentSkillLayout) {
+ persist(layout)
+ self.layout = load()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AIClipboardSkill.swift b/OSGKeyboardShared/Services/AIClipboardSkill.swift
index f7d0b56..3001289 100644
--- a/OSGKeyboardShared/Services/AIClipboardSkill.swift
+++ b/OSGKeyboardShared/Services/AIClipboardSkill.swift
@@ -2,20 +2,57 @@
// OSGKeyboard · Shared
//
// Built-in clipboard actions for AI idle. The catalog is an ordered list so
-// Settings can later persist a subset or permutation without changing the view.
+// Settings / the Skills tab can persist a subset or permutation without
+// changing the view. Transform skills insert into the current field;
+// export skills hand off to a companion Shortcut after the model runs.
import Foundation
+public enum AIClipboardSkillKind: String, Sendable {
+ /// LLM output is reviewed and inserted into the current text field.
+ case transform
+ /// LLM output is parsed and sent to a companion Shortcut. Never inserted.
+ case export
+}
+
public struct AIClipboardSkill: Identifiable, Equatable, Sendable {
public let id: String
public let systemImage: String
- /// Keyboard.strings key for the short button title.
+ /// Keyboard.strings key for the short chip title.
public let titleKey: String
+ /// App Localizable key for the Skills-tab card title. Falls back to `titleKey`.
+ public let cardTitleKey: String
+ public let descriptionKey: String
+ public let kind: AIClipboardSkillKind
+ /// Default skills can be turned off but not removed from the catalog.
+ public let isDefault: Bool
+ /// Frozen companion Shortcut name. Nil for transform skills.
+ public let shortcutName: String?
+ /// Optional `icloud.com/shortcuts/` share URL. Nil → open the bundled file.
+ public let shortcutICloudURL: URL?
- public init(id: String, systemImage: String, titleKey: String) {
+ public var requiresShortcut: Bool { kind == .export }
+
+ public init(
+ id: String,
+ systemImage: String,
+ titleKey: String,
+ cardTitleKey: String,
+ descriptionKey: String,
+ kind: AIClipboardSkillKind,
+ isDefault: Bool,
+ shortcutName: String? = nil,
+ shortcutICloudURL: URL? = nil
+ ) {
self.id = id
self.systemImage = systemImage
self.titleKey = titleKey
+ self.cardTitleKey = cardTitleKey
+ self.descriptionKey = descriptionKey
+ self.kind = kind
+ self.isDefault = isDefault
+ self.shortcutName = shortcutName
+ self.shortcutICloudURL = shortcutICloudURL
}
}
@@ -23,31 +60,68 @@ public enum AIClipboardSkillCatalog: Sendable {
public static let replyID = "reply"
public static let summarizeID = "summarize"
public static let translateID = "translate"
+ public static let extractTodosID = "extractTodos"
+ public static let extractTodosShortcutName = "OSG · 提取待办"
+ public static let extractTodosShortcutICloudURL = URL(
+ string: "https://www.icloud.com/shortcuts/520317da7ae74759b64d5fb069c71f81"
+ )!
- /// Default set, in display order. Future skills append here.
- public static let builtIn: [AIClipboardSkill] = [
+ /// Full built-in catalog, in a stable display order for the Skills tab.
+ public static let catalog: [AIClipboardSkill] = [
AIClipboardSkill(
id: replyID,
systemImage: "arrowshape.turn.up.left.fill",
- titleKey: "keyboard.ai.skill.reply"
+ titleKey: "keyboard.ai.skill.reply",
+ cardTitleKey: "skills.reply.name",
+ descriptionKey: "skills.reply.description",
+ kind: .transform,
+ isDefault: true
),
AIClipboardSkill(
id: summarizeID,
systemImage: "doc.text.magnifyingglass",
- titleKey: "keyboard.ai.skill.summarize"
+ titleKey: "keyboard.ai.skill.summarize",
+ cardTitleKey: "skills.summarize.name",
+ descriptionKey: "skills.summarize.description",
+ kind: .transform,
+ isDefault: true
),
AIClipboardSkill(
id: translateID,
systemImage: "character.bubble.fill",
- titleKey: "keyboard.ai.skill.translate"
+ titleKey: "keyboard.ai.skill.translate",
+ cardTitleKey: "skills.translate.name",
+ descriptionKey: "skills.translate.description",
+ kind: .transform,
+ isDefault: true
+ ),
+ AIClipboardSkill(
+ id: extractTodosID,
+ systemImage: "checklist",
+ titleKey: "keyboard.ai.skill.extractTodos",
+ cardTitleKey: "skills.extractTodos.name",
+ descriptionKey: "skills.extractTodos.description",
+ kind: .export,
+ isDefault: false,
+ shortcutName: extractTodosShortcutName,
+ shortcutICloudURL: extractTodosShortcutICloudURL
),
]
- /// `enabledIDs` is the future Settings hook: `nil` keeps the built-in list.
+ /// Legacy alias: the three default transform skills used to be the whole list.
+ public static let builtIn: [AIClipboardSkill] = catalog
+
+ public static func skill(id: String) -> AIClipboardSkill? {
+ catalog.first { $0.id == id }
+ }
+
+ /// `enabledIDs` is the Skills-tab order. `nil` keeps the default three.
+ /// An explicit empty array shows no chips (carousel fallback).
public static func visible(enabledIDs: [String]? = nil) -> [AIClipboardSkill] {
- guard let enabledIDs, !enabledIDs.isEmpty else { return builtIn }
- let byID = Dictionary(uniqueKeysWithValues: builtIn.map { ($0.id, $0) })
- return enabledIDs.compactMap { byID[$0] }
+ let ids = enabledIDs ?? AIAgentSkillLayout.defaultEnabledIDs
+ guard !ids.isEmpty else { return [] }
+ let byID = Dictionary(uniqueKeysWithValues: catalog.map { ($0.id, $0) })
+ return ids.compactMap { byID[$0] }
}
public static func instruction(
@@ -102,6 +176,18 @@ public enum AIClipboardSkillCatalog: Sendable {
locale: locale,
translationTargetLocaleId: translationTargetLocaleId
)
+ case extractTodosID:
+ return zh
+ ? """
+ 请从剪贴板中只提取明确的待办事项。每条一行,只要标题,不要编号、不要项目符号、不要解释。最多 20 条。
+ 若没有任何可执行的待办,只输出 NONE,不要把整段原文当成一条待办。
+ 若原文本身就是一句短待办(例如「买牛奶」),输出那一句即可。
+ """
+ : """
+ Extract only explicit to-do items from the clipboard. One title per line; no numbering, bullets, or commentary. Maximum 20 lines.
+ If there are no actionable tasks, output NONE and nothing else. Do not treat the whole clipboard as one task.
+ If the clipboard itself is already one short task (for example "buy milk"), output that single line.
+ """
default:
return zh
? "请根据剪贴板内容完成用户选择的操作。"
diff --git a/OSGKeyboardShared/Services/AITodoExtraction.swift b/OSGKeyboardShared/Services/AITodoExtraction.swift
new file mode 100644
index 0000000..262dfc3
--- /dev/null
+++ b/OSGKeyboardShared/Services/AITodoExtraction.swift
@@ -0,0 +1,82 @@
+// AITodoExtraction.swift
+// OSGKeyboard · Shared
+//
+// Parses the LLM's extract-todos reply into reminder titles. Fail closed:
+// empty / NONE / "no tasks" never become a Shortcut run. A single long
+// echo of the clipboard is also rejected so the model cannot dump the
+// whole paste as one reminder.
+
+import Foundation
+
+public enum AITodoExtraction: Sendable {
+ public static let maximumItems = 20
+
+ private static let emptyTokens: Set = [
+ "none", "no", "n/a", "na", "nil", "null",
+ "无", "没有", "没有待办", "没有待办事项", "无待办", "无待办事项",
+ "no tasks", "no task", "no todos", "no to-dos", "no to-do",
+ "no actionable items", "no action items",
+ ]
+
+ /// Titles to send to the companion Shortcut. Empty → do not run it.
+ public static func items(from raw: String, sourceClipboard: String? = nil) -> [String] {
+ let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !trimmed.isEmpty else { return [] }
+ if isEmptyToken(trimmed) { return [] }
+
+ var seen = Set()
+ var items: [String] = []
+ for line in trimmed.components(separatedBy: .newlines) {
+ let title = stripBullet(line)
+ guard !title.isEmpty, !isEmptyToken(title) else { continue }
+ let key = title.lowercased()
+ guard seen.insert(key).inserted else { continue }
+ items.append(title)
+ if items.count == maximumItems { break }
+ }
+
+ if items.count == 1, isWholeClipboardEcho(items[0], source: sourceClipboard) {
+ return []
+ }
+ return items
+ }
+
+ private static func isEmptyToken(_ text: String) -> Bool {
+ let folded = text
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .trimmingCharacters(in: CharacterSet(charactersIn: "。.!!"))
+ .lowercased()
+ return emptyTokens.contains(folded)
+ }
+
+ private static func stripBullet(_ line: String) -> String {
+ var text = line.trimmingCharacters(in: .whitespacesAndNewlines)
+ let prefixes = ["- ", "* ", "• ", "、"]
+ for prefix in prefixes where text.hasPrefix(prefix) {
+ text = String(text.dropFirst(prefix.count))
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ if let dotted = text.range(of: #"^\d+[\.\)、]\s*"#, options: .regularExpression) {
+ text = String(text[dotted.upperBound...])
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ return text
+ }
+
+ /// One long line that is essentially the clipboard body is not a todo.
+ private static func isWholeClipboardEcho(_ item: String, source: String?) -> Bool {
+ guard let source, source.count > 80, item.count > 80 else { return false }
+ let a = collapse(item)
+ let b = collapse(source)
+ guard !a.isEmpty, !b.isEmpty else { return false }
+ if a == b { return true }
+ return a.contains(b) || b.contains(a)
+ }
+
+ private static func collapse(_ text: String) -> String {
+ text.components(separatedBy: .whitespacesAndNewlines)
+ .filter { !$0.isEmpty }
+ .joined(separator: " ")
+ .lowercased()
+ }
+}
diff --git a/OSGKeyboardShared/Services/AppGroupStore.swift b/OSGKeyboardShared/Services/AppGroupStore.swift
index 0357a30..80b2ab4 100644
--- a/OSGKeyboardShared/Services/AppGroupStore.swift
+++ b/OSGKeyboardShared/Services/AppGroupStore.swift
@@ -97,6 +97,10 @@ public struct AppGroupStore: @unchecked Sendable {
public var polishProviderIdOverride: String? { configuration.polishProviderIdOverride }
public var isCloudAPIKeyMissingForVoiceInput: Bool { configuration.isCloudAPIKeyMissingForVoiceInput }
public var localASRCustomLanguageModelEnabled: Bool { configuration.localASRCustomLanguageModelEnabled }
+ /// Kept off `AppGroupConfiguration.save()` so other settings writes cannot clobber it.
+ public var agentSkillLayout: AIAgentSkillLayout {
+ Self.decodeAgentSkillLayout(from: defaults)
+ }
// MARK: - Writes
@@ -199,6 +203,59 @@ public struct AppGroupStore: @unchecked Sendable {
mutateConfiguration { $0.localASRCustomLanguageModelEnabled = enabled }
}
+ public func setAgentSkillLayout(_ layout: AIAgentSkillLayout) {
+ do {
+ let data = try JSONEncoder().encode(layout.sanitized())
+ defaults.set(data, forKey: AppGroupConfiguration.Keys.agentSkillLayout)
+ } catch {
+ OSGLog.config.warning("agentSkillLayout encode failed: \(error.localizedDescription, privacy: .public)")
+ }
+ AppGroupConfigDarwin.postConfigChanged()
+ }
+
+ public func setPendingShortcutRun(skillID: String, titles: [String]) {
+ let payload = AIAgentShortcutRunPayload(skillID: skillID, titles: titles)
+ if let data = AIAgentShortcutRun.encode(payload) {
+ defaults.set(data, forKey: AIAgentShortcutRun.pendingKey)
+ AIAgentShortcutRun.trace(
+ "appGroup.writePending skill=\(skillID) items=\(titles.count) bytes=\(data.count)"
+ )
+ } else {
+ AIAgentShortcutRun.trace("appGroup.writePending FAILED encode skill=\(skillID)")
+ }
+ }
+
+ public func consumePendingShortcutRun(now: Date = Date()) -> AIAgentShortcutRunPayload? {
+ let data = defaults.data(forKey: AIAgentShortcutRun.pendingKey)
+ defaults.removeObject(forKey: AIAgentShortcutRun.pendingKey)
+ guard let data else {
+ AIAgentShortcutRun.trace("appGroup.consumePending missing")
+ return nil
+ }
+ guard let payload = AIAgentShortcutRun.decode(data, now: now) else {
+ AIAgentShortcutRun.trace(
+ "appGroup.consumePending dropped bytes=\(data.count) (expired or empty titles)"
+ )
+ return nil
+ }
+ AIAgentShortcutRun.trace(
+ "appGroup.consumePending ok skill=\(payload.skillID) items=\(payload.titles.count)"
+ )
+ return payload
+ }
+
+ private static func decodeAgentSkillLayout(from defaults: UserDefaults) -> AIAgentSkillLayout {
+ guard let data = defaults.data(forKey: AppGroupConfiguration.Keys.agentSkillLayout) else {
+ return .default
+ }
+ do {
+ return try JSONDecoder().decode(AIAgentSkillLayout.self, from: data).sanitized()
+ } catch {
+ OSGLog.config.warning("agentSkillLayout decode failed: \(error.localizedDescription, privacy: .public)")
+ return .default
+ }
+ }
+
public var hasCompletedOnboarding: Bool {
get { configuration.hasCompletedOnboarding }
set { setHasCompletedOnboarding(newValue) }
diff --git a/OSGKeyboardShared/Services/KeyboardState.swift b/OSGKeyboardShared/Services/KeyboardState.swift
index ee49223..6e923ee 100644
--- a/OSGKeyboardShared/Services/KeyboardState.swift
+++ b/OSGKeyboardShared/Services/KeyboardState.swift
@@ -140,6 +140,12 @@ public final class KeyboardState: ObservableObject {
@Published public var clipboardHistoryEnabled: Bool = false
/// Opt-in clipboard suggestion strip (requires history enabled).
@Published public var clipboardCandidateBarEnabled: Bool = false
+ /// Skills-tab order for clipboard chips (max 8). Empty → hint carousel.
+ @Published public var enabledClipboardSkillIDs: [String] = AIAgentSkillLayout.defaultEnabledIDs
+ /// Export skill currently waiting on the LLM. Nil for transform skills.
+ @Published public var pendingClipboardSkillID: String?
+ /// In-keyboard toast (e.g. no todos). Does not leave the host app.
+ @Published public var skillTipText: String?
/// Host field is a password / secure entry — never read pasteboard.
@Published public var isSecureTextEntry: Bool = false
/// Secure fields hide every clipboard-history entry point.
@@ -239,8 +245,10 @@ public final class KeyboardState: ObservableObject {
public var sendAIAnswer: () -> Void = {}
/// Sends a tapped idle hint card as the AI question (skip microphone).
public var submitAIHint: (AIHintCard) -> Void = { _ in }
- /// Sends a clipboard skill (reply / summarize / translate / future).
+ /// Sends a clipboard skill (reply / summarize / translate / export).
public var submitAIClipboardSkill: (AIClipboardSkill) -> Void = { _ in }
+ /// Writes extract-todos titles and opens the host to run the Shortcut.
+ public var runClipboardExportSkill: (String, [String]) -> Void = { _, _ in }
public var openSettings: () -> Void = {}
/// Opens the host app straight to input-resource deployment. Used by the
/// typing surface when Rime resources have not been deployed yet.
diff --git a/OSGKeyboardShared/Utilities/OSGDiag.swift b/OSGKeyboardShared/Utilities/OSGDiag.swift
index 83d5d05..b0eef3c 100644
--- a/OSGKeyboardShared/Utilities/OSGDiag.swift
+++ b/OSGKeyboardShared/Utilities/OSGDiag.swift
@@ -18,6 +18,8 @@ public enum OSGDiag {
OSGLog.keyboardExt.info("\(line, privacy: .public)")
case "flow", "asr":
OSGLog.flow.info("\(line, privacy: .public)")
+ case "skills":
+ OSGLog.config.info("\(line, privacy: .public)")
default:
OSGLog.config.info("\(line, privacy: .public)")
}
diff --git a/OSGKeyboardTests/AIAgentSkillLayoutTests.swift b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift
new file mode 100644
index 0000000..1b46f05
--- /dev/null
+++ b/OSGKeyboardTests/AIAgentSkillLayoutTests.swift
@@ -0,0 +1,199 @@
+// AIAgentSkillLayoutTests.swift
+// OSGKeyboardTests
+
+import XCTest
+@testable import OSGKeyboardShared
+
+@MainActor
+final class AIAgentSkillLayoutTests: XCTestCase {
+ private func makeDefaults() -> UserDefaults {
+ let suite = "group.com.osgkeyboard.shared.tests.skills.\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ return defaults
+ }
+
+ func testFreshInstallEnablesDefaultTransformSkills() {
+ let defaults = makeDefaults()
+ let layout = AppGroupStore(defaults: defaults).agentSkillLayout
+ XCTAssertEqual(layout.enabledIDs, AIAgentSkillLayout.defaultEnabledIDs)
+ XCTAssertTrue(layout.confirmedShortcutIDs.isEmpty)
+ }
+
+ func testEmptyEnabledListIsPreserved() {
+ let defaults = makeDefaults()
+ let store = AppGroupStore(defaults: defaults)
+ store.setAgentSkillLayout(
+ AIAgentSkillLayout(enabledIDs: [], confirmedShortcutIDs: [])
+ )
+ XCTAssertEqual(store.agentSkillLayout.enabledIDs, [])
+ }
+
+ func testCannotEnableExportSkillBeforeShortcutConfirmation() {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ XCTAssertEqual(
+ store.enable(AIClipboardSkillCatalog.extractTodosID),
+ .needsShortcut
+ )
+ XCTAssertFalse(store.layout.isEnabled(AIClipboardSkillCatalog.extractTodosID))
+ }
+
+ func testConfirmShortcutAutoEnablesWhenSlotAvailable() {
+ let store = AIAgentSkillLayoutStore(defaults: makeDefaults())
+ XCTAssertEqual(
+ store.confirmShortcutAndEnable(AIClipboardSkillCatalog.extractTodosID),
+ .enabled
+ )
+ XCTAssertTrue(store.layout.hasConfirmedShortcut(AIClipboardSkillCatalog.extractTodosID))
+ XCTAssertEqual(
+ store.layout.enabledIDs.last,
+ AIClipboardSkillCatalog.extractTodosID
+ )
+ }
+
+ func testSanitizedDropsUnconfirmedExportAndUnknownIDs() {
+ let layout = AIAgentSkillLayout(
+ enabledIDs: ["reply", "extractTodos", "unknown"],
+ confirmedShortcutIDs: []
+ ).sanitized()
+ XCTAssertEqual(layout.enabledIDs, ["reply"])
+ }
+
+ func testSanitizedKeepsConfirmedExport() {
+ let layout = AIAgentSkillLayout(
+ enabledIDs: ["reply", "extractTodos"],
+ confirmedShortcutIDs: ["extractTodos"]
+ ).sanitized()
+ XCTAssertEqual(layout.enabledIDs, ["reply", "extractTodos"])
+ }
+
+ func testIsFullUsesEnabledCount() {
+ let full = AIAgentSkillLayout(
+ enabledIDs: (0.. dict:
+ return {"Value": value, "WFSerializationType": "WFTextTokenAttachment"}
+
+
+def token_string(attachment_value: dict) -> dict:
+ return {
+ "Value": {
+ "attachmentsByRange": {"{0, 1}": attachment_value},
+ "string": OBJECT_REPLACEMENT,
+ },
+ "WFSerializationType": "WFTextTokenString",
+ }
+
+
+def shortcut_input_text() -> dict:
+ return token_string({"Type": "ExtensionInput"})
+
+
+def action_output_text(output_name: str, output_uuid: str) -> dict:
+ return token_string(
+ {
+ "Aggrandizements": [],
+ "OutputName": output_name,
+ "OutputUUID": output_uuid,
+ "Type": "ActionOutput",
+ }
+ )
+
+
+def named_variable_text(variable_name: str) -> dict:
+ return token_string(
+ {
+ "Aggrandizements": [],
+ "Type": "Variable",
+ "VariableName": variable_name,
+ }
+ )
+
+
+def workflow() -> dict:
+ return {
+ "WFWorkflowClientVersion": "3600.0.4",
+ "WFWorkflowClientRelease": "26.0",
+ "WFWorkflowMinimumClientVersion": 900,
+ "WFWorkflowMinimumClientVersionString": "900",
+ "WFWorkflowName": "OSG · 提取待办",
+ "WFWorkflowHasShortcutInputVariables": True,
+ "WFWorkflowHasOutputFallback": False,
+ "WFWorkflowImportQuestions": [],
+ # Empty types: a normal library shortcut. QuickActions/ActionExtension
+ # made `run-shortcut?name=` open Shortcuts without running it.
+ "WFWorkflowTypes": [],
+ "WFWorkflowInputContentItemClasses": [
+ "WFStringContentItem",
+ "WFRichTextContentItem",
+ ],
+ "WFWorkflowOutputContentItemClasses": [],
+ "WFWorkflowNoInputBehavior": {
+ "Name": "WFWorkflowNoInputBehaviorAskForInput",
+ "Parameters": {
+ "ItemClass": "WFStringContentItem",
+ },
+ },
+ "WFWorkflowIcon": {
+ "WFWorkflowIconGlyphNumber": 59511,
+ "WFWorkflowIconStartColor": 2071128575,
+ },
+ "WFWorkflowActions": [
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.gettext",
+ "WFWorkflowActionParameters": {
+ "UUID": TEXT_UUID,
+ "CustomOutputName": "快捷指令文本",
+ "WFTextActionText": shortcut_input_text(),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.text.split",
+ "WFWorkflowActionParameters": {
+ "UUID": SPLIT_UUID,
+ # Split Text reads `text`, not `WFInput`.
+ "text": action_output_text("快捷指令文本", TEXT_UUID),
+ "WFTextSeparator": "New Lines",
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_START,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 0,
+ "WFInput": attachment(
+ {
+ "Aggrandizements": [],
+ "OutputName": "Split Text",
+ "OutputUUID": SPLIT_UUID,
+ "Type": "ActionOutput",
+ }
+ ),
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.addnewreminder",
+ "WFWorkflowActionParameters": {
+ "UUID": REMINDER_UUID,
+ "WFCalendarItemTitle": named_variable_text("Repeat Item"),
+ "WFCalendarItemShowComposer": False,
+ "ShowComposeSheet": False,
+ "WFShowWhenRun": False,
+ },
+ },
+ {
+ "WFWorkflowActionIdentifier": "is.workflow.actions.repeat.each",
+ "WFWorkflowActionParameters": {
+ "UUID": REPEAT_END,
+ "GroupingIdentifier": REPEAT_GROUP,
+ "WFControlFlowMode": 2,
+ },
+ },
+ ],
+ }
+
+
+def main() -> None:
+ OUT_DIR.mkdir(parents=True, exist_ok=True)
+ UNSIGNED.write_bytes(plistlib.dumps(workflow(), fmt=plistlib.FMT_BINARY))
+ print(f"wrote {UNSIGNED}")
+ print(
+ "sign with: shortcuts sign --mode anyone "
+ f"--input {UNSIGNED} --output {SIGNED}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Tests/suite-manifest.json b/Tests/suite-manifest.json
index dd21bd0..5486e70 100644
--- a/Tests/suite-manifest.json
+++ b/Tests/suite-manifest.json
@@ -132,6 +132,8 @@
"OSGKeyboardTests/CursorNavigationTests",
"OSGKeyboardTests/KeyboardTranslationConfigProtectionTests",
"OSGKeyboardTests/AIHintPoolTests",
+ "OSGKeyboardTests/AIHintKeywordExtractorTests",
+ "OSGKeyboardTests/AIAgentSkillLayoutTests",
"OSGKeyboardTests/ClipboardHistoryPolicyTests",
"OSGKeyboardTests/ClipboardHistoryStoreTests"
]
diff --git a/project.yml b/project.yml
index 00a0157..48c18a4 100644
--- a/project.yml
+++ b/project.yml
@@ -76,6 +76,7 @@ targets:
# crashes when both are passed. iOS 26 uses Icon Composer only.
- "Assets.xcassets/AppIcon.appiconset"
- "Resources/CustomLanguageModel/**"
+ - "Resources/Shortcuts/**"
# Real keyboard chrome for DEBUG what's-new recording (same UI as Ext).
- path: OSGKeyboardExt/Utilities/ExtL10n.swift
- path: OSGKeyboardExt/Views/AIKeyboardView.swift
@@ -120,6 +121,7 @@ targets:
- path: OSGKeyboard/Resources/Typing/Rime/manifest.json
- path: OSGKeyboard/Resources/HostCLM/v1/OSGKeyboardCLM.bin
- path: OSGKeyboard/Resources/HostCLM/v1/compiled-manifest.json
+ - path: OSGKeyboard/Resources/Shortcuts/OSGExtractTodos.shortcut
- path: OSGKeyboard/Resources/LocalASR/local-asr-catalog.json
- path: OSGKeyboard/Resources/Typing/Licenses/NOTICE.txt
- path: OSGKeyboard/Resources/Typing/Licenses/LICENSE.txt
@@ -194,6 +196,7 @@ targets:
- zalo
- skype
- zoomus
+ - shortcuts
- things
- todoist
- evernote
From 0a60006d2d6489c2a86fec057c757a2415773fd2 Mon Sep 17 00:00:00 2001
From: Rocky <72559939+hkgood@users.noreply.github.com>
Date: Thu, 13 Aug 2026 23:01:43 +0800
Subject: [PATCH 2/3] feat(keyboard): add custom skills plus events and
navigate Shortcuts
Ship user-defined Shortcut skills, companion Events/Navigate recipes, shared skill/style card chrome, and bump the build to 69.
---
.gitignore | 1 +
AGENTS.md | 2 +-
CHANGELOG.md | 9 +
OSGKeyboard/Info.plist | 4 +
OSGKeyboard/OSGKeyboardApp.swift | 2 +
.../Shortcuts/OSGExtractEvents.shortcut | Bin 0 -> 23409 bytes
.../OSGExtractEvents.unsigned.shortcut | Bin 0 -> 3288 bytes
.../Resources/Shortcuts/OSGNavigate.shortcut | Bin 0 -> 22013 bytes
.../Shortcuts/OSGNavigate.unsigned.shortcut | Bin 0 -> 1366 bytes
.../Services/AIAgentShortcutInstaller.swift | 18 +-
.../Services/AIAgentShortcutRunner.swift | 30 +-
OSGKeyboard/Services/FlowSessionManager.swift | 10 +-
OSGKeyboard/Views/AIAgentSkillsView.swift | 9063 ++++++++++++++++-
.../AIClipboardSkillLayoutDemoView.swift | 136 +
OSGKeyboard/Views/CatalogCardChrome.swift | 31 +
OSGKeyboard/Views/PolishStylesView.swift | 18 +-
OSGKeyboard/en.lproj/Localizable.strings | 39 +-
OSGKeyboard/zh-Hans.lproj/Localizable.strings | 39 +-
.../Services/AIKeyboardCoordinator.swift | 46 +-
.../Services/KeyboardFlowCoordinator.swift | 10 +-
OSGKeyboardExt/Views/AIKeyboardView.swift | 102 +-
OSGKeyboardExt/en.lproj/Keyboard.strings | 10 +
OSGKeyboardExt/zh-Hans.lproj/Keyboard.strings | 10 +
OSGKeyboardMac/MacPolishStylesView.swift | 16 +-
.../Models/AIAgentSkillLayout.swift | 6 +-
OSGKeyboardShared/Models/AIUserSkill.swift | 177 +
.../Models/AppGroupConfiguration.swift | 2 +
.../Models/FlowSession/FlowCommand.swift | 4 +
.../Models/FlowUtteranceRequest.swift | 12 +-
.../Services/AIAddressExtraction.swift | 148 +
.../Services/AIAgentSkillLayoutStore.swift | 123 +-
.../Services/AIClipboardSkill.swift | 154 +-
.../Services/AIEventExtraction.swift | 279 +
.../Services/AIGenericSkillExport.swift | 25 +
.../Services/AIMapNavigation.swift | 126 +
.../Services/AIModeLLMClientFactory.swift | 9 +-
.../Services/AIQuestionService.swift | 6 +-
.../Services/AIShortcutShareLink.swift | 94 +
.../Services/AppGroupStore.swift | 47 +-
.../AIAddressExtractionTests.swift | 180 +
.../AIAgentSkillLayoutTests.swift | 63 +-
OSGKeyboardTests/AIEventExtractionTests.swift | 143 +
.../AIHintKeywordExtractorTests.swift | 20 +
OSGKeyboardTests/AIModeLLMClientTests.swift | 12 +
OSGKeyboardTests/AIUserSkillTests.swift | 189 +
OSGKeyboardTests/FlowSessionBridgeTests.swift | 41 +
Scripts/generate_extract_events_shortcut.py | 272 +
Scripts/generate_navigate_shortcut.py | 113 +
Tests/suite-manifest.json | 4 +
project.yml | 18 +-
50 files changed, 11612 insertions(+), 251 deletions(-)
create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut
create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut
create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut
create mode 100644 OSGKeyboard/Resources/Shortcuts/OSGNavigate.unsigned.shortcut
create mode 100644 OSGKeyboard/Views/AIClipboardSkillLayoutDemoView.swift
create mode 100644 OSGKeyboard/Views/CatalogCardChrome.swift
create mode 100644 OSGKeyboardShared/Models/AIUserSkill.swift
create mode 100644 OSGKeyboardShared/Services/AIAddressExtraction.swift
create mode 100644 OSGKeyboardShared/Services/AIEventExtraction.swift
create mode 100644 OSGKeyboardShared/Services/AIGenericSkillExport.swift
create mode 100644 OSGKeyboardShared/Services/AIMapNavigation.swift
create mode 100644 OSGKeyboardShared/Services/AIShortcutShareLink.swift
create mode 100644 OSGKeyboardTests/AIAddressExtractionTests.swift
create mode 100644 OSGKeyboardTests/AIEventExtractionTests.swift
create mode 100644 OSGKeyboardTests/AIUserSkillTests.swift
create mode 100644 Scripts/generate_extract_events_shortcut.py
create mode 100644 Scripts/generate_navigate_shortcut.py
diff --git a/.gitignore b/.gitignore
index 397ba4a..26f7e1b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,5 +87,6 @@ CHANGES.md
# Local scratch: DerivedData clones, stress logs, QA frames, AI notes, audits
.tmp/
+.ai-skills-layout-shots/
.workbuddy-ai/
reports/
diff --git a/AGENTS.md b/AGENTS.md
index b9db30d..45a767c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,7 +14,7 @@ writing commit messages that will ship to users.
### Version format
-The current source-of-truth version is **1.7.5 (build 67)**. Releases use stable SemVer:
+The current source-of-truth version is **1.7.5 (build 69)**. Releases use stable SemVer:
| Field | File | Rule |
|-------|------|------|
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1edcc90..902cb69 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,11 +10,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- **Skills tab**: Home dock and iPad sidebar add Skills between Home and Styles; cards list default Reply / Summarize / Translate plus installable export skills (max 8 enabled, long-press drag to reorder). / **技能 Tab**:首页 Dock 与 iPad 侧栏在「首页」和「风格」之间新增「技能」;卡片列出默认的回复 / 总结 / 翻译以及可安装的出口技能(最多启用 8 个,长按拖动排序)。
- **Extract tasks skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取待办` on the system Add page (split lines → Reminders, default list). After you tap Add, copying text and tapping Tasks asks the LLM for to-dos and silently adds them; no tasks stays in the current app with a keyboard tip. / **提取待办技能**:技能页会打开已做好的配套捷径 `OSG · 提取待办` 的系统添加页(按行拆分并写入默认提醒清单)。点添加后,复制文字并点「待办」会让模型抽取待办并静默写入;没有待办则留在当前 App,键盘上给出提示。
+- **Extract events skill**: the Skills tab opens a ready-made companion Shortcut named `OSG · 提取日程` on the system Add page (multiple events into Calendar; date-only → all-day, time-only → today, optional end time and location). After you tap Add, copying text and tapping Events asks the LLM for events and silently adds them; no date or time stays in the current app with a keyboard tip. / **提取日程技能**:技能页会打开已做好的配套捷径 `OSG · 提取日程` 的系统添加页(可多条写入日历;只有日期为全天;只有时刻用当天;可带结束时间与地点)。点添加后,复制文字并点「日程」会让模型抽取日程并静默写入;没有日期或时间则留在当前 App,键盘上给出提示。
+- **Navigate skill**: the Skills tab opens a companion Shortcut named `OSG · 导航`. After you add it, copying text and tapping Navigate asks the LLM for one address (or origin → destination) and opens driving directions — Amap if installed, then Baidu Maps, then Apple Maps. No address stays in the current app with a keyboard tip. / **导航技能**:技能页打开配套捷径 `OSG · 导航`。添加后,复制文字并点「导航」会抽取一条地址(或起点→终点)并开始驾车导航——已装高德则用高德,否则百度,再否则 Apple 地图。没有地址则留在当前 App,键盘上给出提示。
+- **Skills clipboard guide**: when Clipboard History is off, the Skills tab shows a card that jumps to in-app Clipboard settings and to iOS Settings for paste authorization. / **技能页剪贴板指引**:未开启剪贴板历史时,技能页展示可点击卡片,分别跳转 App 内剪贴板设置和系统设置以完成粘贴授权。
+- **Custom skills**: Skills tab `+` adds a user skill (name, about, SF Symbol, prompt, required iCloud Shortcut link with name lookup, independent Shortcut name, thinking off by default). Built-in thinking stays off and disabled. No cap on how many custom skills you can save; the keyboard still holds at most 8. / **自定义技能**:技能页右上角 `+` 可添加用户技能(名称、介绍、SF Symbol、提示词、必填 iCloud 捷径链接并自动读取名称、可与技能名分开的捷径名、思考默认关)。内置技能思考固定关闭且不可开。自定义数量不设上限,键盘仍最多启用 8 个。
### Fixed
- **Extract tasks Shortcut**: receive Shortcut Input as Text, then split lines and add each title to Reminders — the previous recipe could finish successfully without creating items. / **提取待办捷径**:先把快捷指令输入收成文本,再按行写入提醒;旧配方会成功跑完但不创建条目。
+- **Skill reorder feedback**: long-press lifts a skill card and the grid slides live under the finger, matching Home Screen rearrange. / **技能拖动排序**:长按拎起技能卡片,网格随手指实时让位,接近主屏幕图标重排。
+- **Skill drag preview corners**: the lift preview clips to the card’s continuous rounded rect so square white corners no longer show. / **技能拖动圆角**:拖起预览按卡片连续圆角裁剪,去掉圆角外的直角白底。
### Changed
+- **Skill and style cards**: every card uses a top-right edit pencil and a bottom-right selected check; skill Done sits on the right like style details; tapping a skill card toggles the keyboard slot (unconfirmed Shortcut skills still open the install sheet). / **技能与风格卡片**:右上为编辑铅笔、右下为选中勾选;技能详情「完成」改到右侧;点技能卡片切换是否上键盘(未确认捷径的仍打开安装页)。
+- **Clipboard skill grid**: idle skill chips stay as one centered block, wrapping to two rows of up to four instead of scrolling sideways after four. / **剪贴板技能网格**:空闲技能按钮作为一整块居中,超过四个改为两行(每行最多四个),不再横向滚动。
+- **Skill sheet buttons**: Add, Turn off, Add Shortcut, I’ve added it, and Reinstall are solid capsules — primary actions use the brand green (`#3AA05A`), secondary actions use an elevated fill. / **技能弹窗按钮**:添加、关闭、添加捷径、我已添加、重新安装改为实心胶囊;主操作为品牌绿(`#3AA05A`),次要为抬升底。
- **AI idle hint keywords**: chips show the entity (from feed `metadata.title` / city / holiday name) instead of a long sentence; category prefixes like「全网热点:」are dropped, and LLM compression is only a last resort. / **AI 空闲建议关键词**:芯片展示实体(来自 feed 的 `metadata.title` / 城市 / 节日名)而不再是长句;去掉「全网热点:」一类前缀,LLM 压缩仅作兜底。
- **AI idle hint chrome**: each rotating suggestion sits in a Liquid Glass capsule with a category SF Symbol (calendar, weather, news, stocks, trending, search). / **AI 空闲建议样式**:轮播建议放入 Liquid Glass 胶囊,左侧为类型 SF Symbol(日历、天气、新闻、股票、热搜、搜索)。
- **Clipboard AI skills**: within 30 seconds of a copy, AI idle shows compact circular Reply / Summarize / Translate buttons; translate follows the keyboard language setting, or Chinese ↔ English when unset. The skill list is catalog-based so more actions can be added later. / **剪贴板 AI 技能**:复制后约 30 秒内,空闲态改为小圆形「回复 / 总结 / 翻译」按钮;翻译跟随键盘目标语言,未设置时中英互译。技能来自目录,便于日后扩展。
diff --git a/OSGKeyboard/Info.plist b/OSGKeyboard/Info.plist
index d06719a..78992f7 100644
--- a/OSGKeyboard/Info.plist
+++ b/OSGKeyboard/Info.plist
@@ -70,6 +70,10 @@
skype
zoomus
shortcuts
+ iosamap
+ amapuri
+ baidumap
+ maps
things
todoist
evernote
diff --git a/OSGKeyboard/OSGKeyboardApp.swift b/OSGKeyboard/OSGKeyboardApp.swift
index 3021bfb..e9f8fb3 100644
--- a/OSGKeyboard/OSGKeyboardApp.swift
+++ b/OSGKeyboard/OSGKeyboardApp.swift
@@ -36,6 +36,8 @@ struct OSGKeyboardApp: App {
EditDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--ai-demo") {
AIKeyboardDemoView()
+ } else if ProcessInfo.processInfo.arguments.contains("--ai-skills-demo") {
+ AIClipboardSkillLayoutDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--clipboard-demo") {
ClipboardHistoryDemoView()
} else if ProcessInfo.processInfo.arguments.contains("--edit-pager-ui-test") {
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.shortcut
new file mode 100644
index 0000000000000000000000000000000000000000..e1f601f9c1c817461d6950f8506814102d91db0b
GIT binary patch
literal 23409
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
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGExtractEvents.unsigned.shortcut
new file mode 100644
index 0000000000000000000000000000000000000000..a467a09a7cace7eb5dcfbc4164009efe95098d44
GIT binary patch
literal 3288
zcmaJ@dz2I96;Ec8$xLW#c dE-Y9TzU^c(c~wy73EM6UY<4#+kByULb~kKZWF{<&
zi1EdjCo0
ze)qfI{r&F!?){R*Y{oEI_IFN#bh#-H&3MOs=Chx>;lyjhTYvt`U%mDAJEt#PO!&sf
z)p()Q-;*s22q`mD$QudYja8kL&FFcvP0#9@p#sS~wBTDow5C_||
ze6QYVSgfC!F|+eNc5Z`z!H`fenY?ZQX7*OV<)3|K26ryN2OC3EriS4Fp|`iB<QWK0K6z$pryGeTuJyQ+q7Dl5w0c~XGc9hxo*K7a~3v#>B~U!n4MjWSCf!j5$}Jc
zdN_}QAc$xEJQ9OS2>AmdC&1A%8{n4?b=tcjhDA65t&o7lX9JB(mbS%KUg<8l!Yj+J
ze647+NNm9E0%GdugbsB*uHn*Tz@(>C$l+!Rksr2|JFx->q=pg4O93$|3yPl)Lf9*9>Q*gm4p)56RoGR7U^ZCJ
zad?`2a59zdO(gF>+ncDm4xumz;<-Qw$zdUa0zsLV1VvQ2Ump6a>m97(r@O5>PO#h7^f~dlQa;v$E}Mt>w%G&IZ?iLdC-{NM~^8(6Sfqth;Y$
z-uw;s4?J+sS0D#a-u9B!mal$kS^GBGJ)s&?6bT@R4~C+M6=V^GB5YWd{hShvM5|^3
z5iM)*
z!X+VOv$8g5_0w9lteJS&)m>}cQb<|N)*2PH<~7H_9DzxH&7!H5OziMur?h9xtiB35
zIYb%n)AMcRJY6MBL=p&s#EOWMR2e1$2Yn>KvtdDGfThRr#oPuL^V?RFc%ozgThr>d
zWA#&@e%exhr)#;T-htKM1NAeOy0W_8;&yP?S!j40p6#w4EFl>L@sX&6U?wRjDh33O
z<%9l+DB*NG_ghT%^ETVxAKtoW|L~7SfNtnJwZN5p+J=2)yvaxbL3~sI?}}eVg2amf
ze=x*FB^e_B0S<95gt*TN@dYdW)9Mdk^^2hXp{0Il4Cl*O{bNwSVyO>~;d~XV4}tn8
zminhxa2~O69__8-RFDdSctKQ9P~})8%0Y?eLg9cQD}Xb43~#yXc+D0Su^@;SB^8C(
zfFDUpNDcQN$v3Wf*x(1)g6v?iueF>@;s?i+>T3kA
z#c-iWg>0cT{x8!eTz>Ga%avWH-n3`EHNW91UU`pMWVdBfeR$5+=*N=}#iA^`+OgOX
zb1ZN)I7S=|snf9w`k&U18$C06_FOw|+RJNSHZ>2n|9g4~M!#ApS+niT`BPo^zF6#AM9%T3<0Z#2$9qnPbB1%FGww8;+nsxzhnz>9?>hfY)DhDOmIx9Hi4J0j
z*hp+5b`ZOW5#lIuoOpvcMVuynPrOfDB0ePR$=PI>oJZbG_K-!gOs*$4kPnmF$!Ey}
zY>(9YpEU7E@~h30(F3TnL1CsPyNH=_4qx@JPA+Qlkt>1hG(7UY0rMo
zG0#QM-)V|&r03HyTBDQn7Wxr-nBGP|NmuBd^z-yS`T#vbze*pWPtdQ^Z`1G4=jeAC
zg7Gl*49~1!?qzhQk6FW%m;q*pd4So>Y+)W_9%r6lo?$A?bIe|5KXZ^7VGc8|F&{Ah
zWG;E9duMv*cv)|Yx7WMcTksaWhIfm1r+2q^kN1f8g!iO()cYUrN8_k*9)~4_)Bbh*
LdhB=XXB_=Mp*}bt
literal 0
HcmV?d00001
diff --git a/OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut b/OSGKeyboard/Resources/Shortcuts/OSGNavigate.shortcut
new file mode 100644
index 0000000000000000000000000000000000000000..75d2304d0476e02b09fa039211bfead360f62a71
GIT binary patch
literal 22013
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