agentix-cli 0.26.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (300) hide show
  1. package/README.md +67 -118
  2. package/apps/mac-helper/Sources/AgentXHelper/AXTree.swift +159 -0
  3. package/apps/mac-helper/Sources/AgentXHelper/Brand.swift +63 -0
  4. package/apps/mac-helper/Sources/AgentXHelper/Focus.swift +97 -0
  5. package/apps/mac-helper/Sources/AgentXHelper/HUD.swift +179 -0
  6. package/apps/mac-helper/Sources/AgentXHelper/OCR.swift +117 -0
  7. package/apps/mac-helper/Sources/AgentXHelper/Pointer.swift +309 -0
  8. package/apps/mac-helper/Sources/AgentXHelper/Screens.swift +67 -0
  9. package/apps/mac-helper/Sources/AgentXHelper/Typer.swift +185 -0
  10. package/apps/mac-helper/Sources/AgentXHelper/Vision.swift +201 -0
  11. package/apps/mac-helper/Sources/AgentXHelper/main.swift +265 -0
  12. package/apps/mac-helper/build.sh +39 -0
  13. package/apps/mac-voice/Resources/Info.plist +18 -0
  14. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +108 -0
  15. package/apps/mac-voice/Sources/AgentXVoice/Brand.swift +123 -0
  16. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +68 -0
  17. package/apps/mac-voice/Sources/AgentXVoice/Hold.swift +74 -0
  18. package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +76 -0
  19. package/apps/mac-voice/Sources/AgentXVoice/Markdown.swift +140 -0
  20. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +275 -0
  21. package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +147 -0
  22. package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +139 -0
  23. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +162 -0
  24. package/apps/mac-voice/Sources/AgentXVoice/SmartPaste.swift +72 -0
  25. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +192 -0
  26. package/apps/mac-voice/Sources/AgentXVoice/StepLabel.swift +159 -0
  27. package/apps/mac-voice/Sources/AgentXVoice/main.swift +380 -0
  28. package/apps/mac-voice/build.sh +32 -0
  29. package/dist/{App-BPNHIGUX.js → App-CKPH3HHF.js} +2 -2
  30. package/dist/{ChatApp-6XSSZB4U.js → ChatApp-MX7IG4K2.js} +2 -2
  31. package/dist/agent-2U574KBZ.js +2 -0
  32. package/dist/article-fields-L4D2BOGY.js +2 -0
  33. package/dist/article-quality-44ONVGLN.js +3 -0
  34. package/dist/article-quality-44ONVGLN.js.map +1 -0
  35. package/dist/{audit-JU3HWISB.js → audit-4245CX6P.js} +2 -2
  36. package/dist/backfill-MTD2B62I.js +6 -0
  37. package/dist/backfill-MTD2B62I.js.map +1 -0
  38. package/dist/{board-dashboard-M3372FLO.js → board-dashboard-QR7CVTKA.js} +1411 -453
  39. package/dist/board-dashboard-QR7CVTKA.js.map +1 -0
  40. package/dist/{builtin-FRKAQMFS.js → builtin-B67DMJ3M.js} +2 -2
  41. package/dist/calibration-ILS5OI44.js +2 -0
  42. package/dist/{chunk-V3U4OF7L.js → chunk-3LYM22IV.js} +46 -48
  43. package/dist/chunk-3LYM22IV.js.map +1 -0
  44. package/dist/chunk-3SCPRYJB.js +5 -0
  45. package/dist/chunk-3SCPRYJB.js.map +1 -0
  46. package/dist/chunk-4BR7D2OU.js +36 -0
  47. package/dist/chunk-4BR7D2OU.js.map +1 -0
  48. package/dist/{chunk-P4VWIVKN.js → chunk-52ZPELD2.js} +403 -29
  49. package/dist/chunk-52ZPELD2.js.map +1 -0
  50. package/dist/chunk-5IA2CYHN.js +5 -0
  51. package/dist/{chunk-O6YPEOQF.js.map → chunk-5IA2CYHN.js.map} +1 -1
  52. package/dist/chunk-64CAIB3C.js +9 -0
  53. package/dist/chunk-64CAIB3C.js.map +1 -0
  54. package/dist/{chunk-5U35WAZF.js → chunk-77OV7346.js} +6 -6
  55. package/dist/chunk-77OV7346.js.map +1 -0
  56. package/dist/chunk-AJ5RMU7X.js +2 -0
  57. package/dist/chunk-AJ5RMU7X.js.map +1 -0
  58. package/dist/chunk-AJONZWLT.js +2 -0
  59. package/dist/chunk-AJONZWLT.js.map +1 -0
  60. package/dist/chunk-BH3GTCZ6.js +2 -0
  61. package/dist/chunk-BH3GTCZ6.js.map +1 -0
  62. package/dist/chunk-E3UJHQGN.js +1189 -0
  63. package/dist/chunk-E3UJHQGN.js.map +1 -0
  64. package/dist/chunk-EVWEG3VD.js +3 -0
  65. package/dist/chunk-EVWEG3VD.js.map +1 -0
  66. package/dist/chunk-F4HZTZHW.js +2 -0
  67. package/dist/chunk-F4HZTZHW.js.map +1 -0
  68. package/dist/chunk-F6CNF7V3.js +25 -0
  69. package/dist/chunk-F6CNF7V3.js.map +1 -0
  70. package/dist/chunk-FIOB3MEX.js +2 -0
  71. package/dist/{chunk-H5D7ATXS.js → chunk-FKBGJYWQ.js} +5 -5
  72. package/dist/chunk-FKBGJYWQ.js.map +1 -0
  73. package/dist/{chunk-BVGJSPW4.js → chunk-G2HMRDUQ.js} +7 -7
  74. package/dist/chunk-G2HMRDUQ.js.map +1 -0
  75. package/dist/chunk-H2QQU2XV.js +109 -0
  76. package/dist/chunk-H2QQU2XV.js.map +1 -0
  77. package/dist/{chunk-LDHPMJ7M.js → chunk-IWMW5432.js} +3 -3
  78. package/dist/chunk-IWMW5432.js.map +1 -0
  79. package/dist/chunk-K33GIBSE.js +2 -0
  80. package/dist/chunk-K33GIBSE.js.map +1 -0
  81. package/dist/chunk-L2WSPY6S.js +2 -0
  82. package/dist/chunk-L2WSPY6S.js.map +1 -0
  83. package/dist/chunk-LCIREYTD.js +54 -0
  84. package/dist/chunk-LCIREYTD.js.map +1 -0
  85. package/dist/chunk-MHIMGM4G.js +126 -0
  86. package/dist/chunk-MHIMGM4G.js.map +1 -0
  87. package/dist/chunk-P5ZBBEPL.js +13 -0
  88. package/dist/chunk-P5ZBBEPL.js.map +1 -0
  89. package/dist/{chunk-JTJIPNXY.js → chunk-PNWNVOEZ.js} +1 -1
  90. package/dist/chunk-PNWNVOEZ.js.map +1 -0
  91. package/dist/chunk-QHTQPBHU.js +45 -0
  92. package/dist/chunk-QHTQPBHU.js.map +1 -0
  93. package/dist/chunk-S2MNL5V6.js +2 -0
  94. package/dist/chunk-S2MNL5V6.js.map +1 -0
  95. package/dist/chunk-SCTIUFL4.js +5 -0
  96. package/dist/{chunk-K2CAPYES.js.map → chunk-SCTIUFL4.js.map} +1 -1
  97. package/dist/chunk-ULLAE6LE.js +157 -0
  98. package/dist/chunk-ULLAE6LE.js.map +1 -0
  99. package/dist/{chunk-DJEZ2HVU.js → chunk-VDMYBPYB.js} +1 -1
  100. package/dist/chunk-VDMYBPYB.js.map +1 -0
  101. package/dist/chunk-VIH7ARPF.js +547 -0
  102. package/dist/chunk-VIH7ARPF.js.map +1 -0
  103. package/dist/chunk-W5C2GDNC.js +32 -0
  104. package/dist/{chunk-JP5IEXR7.js.map → chunk-W5C2GDNC.js.map} +1 -1
  105. package/dist/chunk-WPMX3VGI.js +3 -0
  106. package/dist/chunk-WPMX3VGI.js.map +1 -0
  107. package/dist/chunk-YLBERT7N.js +24 -0
  108. package/dist/chunk-YLBERT7N.js.map +1 -0
  109. package/dist/chunk-YLGIZRHV.js +114 -0
  110. package/dist/chunk-YLGIZRHV.js.map +1 -0
  111. package/dist/chunk-ZMYGXHPT.js +121 -0
  112. package/dist/chunk-ZMYGXHPT.js.map +1 -0
  113. package/dist/chunk-ZTEU5JVQ.js +2 -0
  114. package/dist/chunk-ZTEU5JVQ.js.map +1 -0
  115. package/dist/{chunk-2WVX3LEC.js → chunk-ZVP6GB4G.js} +1 -1
  116. package/dist/chunk-ZVP6GB4G.js.map +1 -0
  117. package/dist/cli.js +1 -1
  118. package/dist/cli.js.map +1 -1
  119. package/dist/client-ZLSXGPTV.js +2 -0
  120. package/dist/{compaction-NWEMOSHA.js → compaction-FD4R23HZ.js} +4 -4
  121. package/dist/config-HMW3EBZL.js +2 -0
  122. package/dist/config-mutator-4SZ5YEVD.js +2 -0
  123. package/dist/{conflict-detector-D7VIYPDI.js → conflict-detector-ZPOT3M2C.js} +2 -2
  124. package/dist/conflict-detector-ZPOT3M2C.js.map +1 -0
  125. package/dist/context-planner-AQHJMVY2.js +17 -0
  126. package/dist/context-planner-AQHJMVY2.js.map +1 -0
  127. package/dist/debug-3MEUO2P2.js +2 -0
  128. package/dist/{discover-XJXQXXIO.js → discover-D4VVRNAI.js} +2 -2
  129. package/dist/discover-D4VVRNAI.js.map +1 -0
  130. package/dist/doctor-LRYV3QLK.js +2 -0
  131. package/dist/editor-chat-OB7IQOB5.js +137 -0
  132. package/dist/editor-chat-OB7IQOB5.js.map +1 -0
  133. package/dist/entry-triage-LK34TSQD.js +2 -0
  134. package/dist/entry-triage-LK34TSQD.js.map +1 -0
  135. package/dist/facts-YVEJOM7O.js +22 -0
  136. package/dist/facts-YVEJOM7O.js.map +1 -0
  137. package/dist/heal-FVLBHYP7.js +2 -0
  138. package/dist/index.d.ts +1040 -64
  139. package/dist/index.js +12 -19
  140. package/dist/index.js.map +1 -1
  141. package/dist/{ingest-whatsapp-LZE62INA.js → ingest-whatsapp-NS4OWDOU.js} +2 -2
  142. package/dist/loader-4ZGTK7LD.js +2 -0
  143. package/dist/loader-BOKE5M5S.js +2 -0
  144. package/dist/memory-extract-F5ZHWY5E.js +2 -0
  145. package/dist/{migrate-v2-QO7SLKIM.js → migrate-v2-V4WN2VVM.js} +2 -2
  146. package/dist/ntfy-X7EPB3TG.js +4 -0
  147. package/dist/ntfy-X7EPB3TG.js.map +1 -0
  148. package/dist/processes-WU23U2OJ.js +2 -0
  149. package/dist/program-GSZ5AYAM.js +2 -0
  150. package/dist/{projects-api-KRME2YKZ.js → projects-api-QE6L65SK.js} +2 -2
  151. package/dist/{projects-mutate-3NHVG437.js → projects-mutate-MY7JTLSZ.js} +2 -2
  152. package/dist/providers-KO2DODIO.js +2 -0
  153. package/dist/query-SQDHE4HV.js +2 -0
  154. package/dist/questions-4B4KSPXG.js +3 -0
  155. package/dist/questions-4B4KSPXG.js.map +1 -0
  156. package/dist/recipes-7S4MC5OJ.js +2 -0
  157. package/dist/registry-LE6FWYZC.js +2 -0
  158. package/dist/review-candidates-RQU5KNZM.js +4 -0
  159. package/dist/review-candidates-RQU5KNZM.js.map +1 -0
  160. package/dist/review-store-B4YE3EFK.js +4 -0
  161. package/dist/review-store-B4YE3EFK.js.map +1 -0
  162. package/dist/{rotation-memo-B3EXXSEH.js → rotation-memo-7T75BLEX.js} +2 -2
  163. package/dist/routing-ILERJXB6.js +2 -0
  164. package/dist/runner-YRBVNYTY.js +2 -0
  165. package/dist/scheduler-BFM3EINS.js +2 -0
  166. package/dist/seat-7WM7II2B.js +2 -0
  167. package/dist/select-X4KVMOP5.js +2 -0
  168. package/dist/select-X4KVMOP5.js.map +1 -0
  169. package/dist/store-MCOA6N4F.js +2 -0
  170. package/dist/{subagent-WV7QKQ3L.js → subagent-2SJHSSJS.js} +2 -2
  171. package/dist/{surface-usage-PRL6Z4U3.js → surface-usage-NS74AXSE.js} +2 -2
  172. package/dist/timers-PCB4ZIBD.js +2 -0
  173. package/dist/triage-backtest-JNO7RKX7.js +2 -0
  174. package/dist/triage-backtest-JNO7RKX7.js.map +1 -0
  175. package/dist/types-H5ENIUIT.js +2 -0
  176. package/dist/types-OIFU3KSQ.js +2 -0
  177. package/dist/types-OIFU3KSQ.js.map +1 -0
  178. package/dist/web/workflow-editor.global.js +1542 -0
  179. package/dist/web/workflow-editor.global.js.map +1 -0
  180. package/dist/{whatsapp-state-XMTCSRXU.js → whatsapp-state-6D6FHHD2.js} +2 -2
  181. package/dist/wiki-2AUFSIKO.js +2 -0
  182. package/dist/wiki-2AUFSIKO.js.map +1 -0
  183. package/dist/workflows/templates/branching.yaml +1 -1
  184. package/dist/{workspace-setup-TBQAHYHJ.js → workspace-setup-B3F3TUDE.js} +2 -2
  185. package/dist/workspace-setup-B3F3TUDE.js.map +1 -0
  186. package/package.json +14 -3
  187. package/scripts/postinstall.mjs +80 -0
  188. package/dist/agent-5QMITZT6.js +0 -2
  189. package/dist/architect-SEPWMWS4.js +0 -2
  190. package/dist/board-dashboard-M3372FLO.js.map +0 -1
  191. package/dist/chunk-2WVX3LEC.js.map +0 -1
  192. package/dist/chunk-3WQFCIB6.js +0 -24
  193. package/dist/chunk-3WQFCIB6.js.map +0 -1
  194. package/dist/chunk-4YCH6IZV.js +0 -2
  195. package/dist/chunk-53NRX2NA.js +0 -2
  196. package/dist/chunk-53NRX2NA.js.map +0 -1
  197. package/dist/chunk-5U35WAZF.js.map +0 -1
  198. package/dist/chunk-BFXONUUN.js +0 -108
  199. package/dist/chunk-BFXONUUN.js.map +0 -1
  200. package/dist/chunk-BFZUG66X.js +0 -45
  201. package/dist/chunk-BFZUG66X.js.map +0 -1
  202. package/dist/chunk-BTHHSY7D.js +0 -13
  203. package/dist/chunk-BTHHSY7D.js.map +0 -1
  204. package/dist/chunk-BVGJSPW4.js.map +0 -1
  205. package/dist/chunk-D46VZ7K7.js +0 -116
  206. package/dist/chunk-D46VZ7K7.js.map +0 -1
  207. package/dist/chunk-DJEZ2HVU.js.map +0 -1
  208. package/dist/chunk-E5CXCI33.js +0 -155
  209. package/dist/chunk-E5CXCI33.js.map +0 -1
  210. package/dist/chunk-FDWOECJY.js +0 -54
  211. package/dist/chunk-FDWOECJY.js.map +0 -1
  212. package/dist/chunk-H5D7ATXS.js.map +0 -1
  213. package/dist/chunk-HR4JSRZS.js +0 -3
  214. package/dist/chunk-HR4JSRZS.js.map +0 -1
  215. package/dist/chunk-ISEBLHWX.js +0 -118
  216. package/dist/chunk-ISEBLHWX.js.map +0 -1
  217. package/dist/chunk-JP5IEXR7.js +0 -32
  218. package/dist/chunk-JTJIPNXY.js.map +0 -1
  219. package/dist/chunk-K2CAPYES.js +0 -5
  220. package/dist/chunk-LDHPMJ7M.js.map +0 -1
  221. package/dist/chunk-METYYZKQ.js +0 -1115
  222. package/dist/chunk-METYYZKQ.js.map +0 -1
  223. package/dist/chunk-MTAPOCGA.js +0 -47
  224. package/dist/chunk-MTAPOCGA.js.map +0 -1
  225. package/dist/chunk-O6YPEOQF.js +0 -5
  226. package/dist/chunk-P4VWIVKN.js.map +0 -1
  227. package/dist/chunk-SR3X25TN.js +0 -3
  228. package/dist/chunk-SR3X25TN.js.map +0 -1
  229. package/dist/chunk-UHE5FCPB.js +0 -431
  230. package/dist/chunk-UHE5FCPB.js.map +0 -1
  231. package/dist/chunk-V3U4OF7L.js.map +0 -1
  232. package/dist/chunk-VJUTQAPJ.js +0 -2
  233. package/dist/chunk-VJUTQAPJ.js.map +0 -1
  234. package/dist/chunk-VT5XYFDD.js +0 -126
  235. package/dist/chunk-VT5XYFDD.js.map +0 -1
  236. package/dist/client-HGKAC6VP.js +0 -2
  237. package/dist/config-A4RWEJHV.js +0 -2
  238. package/dist/config-mutator-MNSFUN7D.js +0 -2
  239. package/dist/conflict-detector-D7VIYPDI.js.map +0 -1
  240. package/dist/context-planner-45GEWKGL.js +0 -17
  241. package/dist/context-planner-45GEWKGL.js.map +0 -1
  242. package/dist/debug-N3B5NVJU.js +0 -2
  243. package/dist/discover-XJXQXXIO.js.map +0 -1
  244. package/dist/doctor-3Q36ZFE6.js +0 -2
  245. package/dist/heal-HZ3EQD52.js +0 -2
  246. package/dist/loader-5CA3O5GM.js +0 -2
  247. package/dist/loader-6IHUCPVO.js +0 -2
  248. package/dist/memory-extract-AENFVVO5.js +0 -2
  249. package/dist/processes-UIC4XXA6.js +0 -2
  250. package/dist/program-SAUNRCCD.js +0 -2
  251. package/dist/providers-L27BJ5A6.js +0 -2
  252. package/dist/query-HZYYRCLU.js +0 -2
  253. package/dist/recipes-EPLE2SNT.js +0 -2
  254. package/dist/registry-HK3WQ7BS.js +0 -2
  255. package/dist/runner-BFYDWPUO.js +0 -2
  256. package/dist/scheduler-6T2SU5ST.js +0 -2
  257. package/dist/store-I3HEWVLQ.js +0 -2
  258. package/dist/timers-5LPZN55J.js +0 -2
  259. package/dist/types-VECTMYTO.js +0 -2
  260. package/dist/types-Y6ZCZ52Z.js +0 -2
  261. package/dist/wiki-M6YNRFC4.js +0 -2
  262. /package/dist/{App-BPNHIGUX.js.map → App-CKPH3HHF.js.map} +0 -0
  263. /package/dist/{ChatApp-6XSSZB4U.js.map → ChatApp-MX7IG4K2.js.map} +0 -0
  264. /package/dist/{agent-5QMITZT6.js.map → agent-2U574KBZ.js.map} +0 -0
  265. /package/dist/{architect-SEPWMWS4.js.map → article-fields-L4D2BOGY.js.map} +0 -0
  266. /package/dist/{audit-JU3HWISB.js.map → audit-4245CX6P.js.map} +0 -0
  267. /package/dist/{builtin-FRKAQMFS.js.map → builtin-B67DMJ3M.js.map} +0 -0
  268. /package/dist/{chunk-4YCH6IZV.js.map → calibration-ILS5OI44.js.map} +0 -0
  269. /package/dist/{client-HGKAC6VP.js.map → chunk-FIOB3MEX.js.map} +0 -0
  270. /package/dist/{config-A4RWEJHV.js.map → client-ZLSXGPTV.js.map} +0 -0
  271. /package/dist/{compaction-NWEMOSHA.js.map → compaction-FD4R23HZ.js.map} +0 -0
  272. /package/dist/{config-mutator-MNSFUN7D.js.map → config-HMW3EBZL.js.map} +0 -0
  273. /package/dist/{debug-N3B5NVJU.js.map → config-mutator-4SZ5YEVD.js.map} +0 -0
  274. /package/dist/{doctor-3Q36ZFE6.js.map → debug-3MEUO2P2.js.map} +0 -0
  275. /package/dist/{heal-HZ3EQD52.js.map → doctor-LRYV3QLK.js.map} +0 -0
  276. /package/dist/{loader-5CA3O5GM.js.map → heal-FVLBHYP7.js.map} +0 -0
  277. /package/dist/{ingest-whatsapp-LZE62INA.js.map → ingest-whatsapp-NS4OWDOU.js.map} +0 -0
  278. /package/dist/{loader-6IHUCPVO.js.map → loader-4ZGTK7LD.js.map} +0 -0
  279. /package/dist/{memory-extract-AENFVVO5.js.map → loader-BOKE5M5S.js.map} +0 -0
  280. /package/dist/{processes-UIC4XXA6.js.map → memory-extract-F5ZHWY5E.js.map} +0 -0
  281. /package/dist/{migrate-v2-QO7SLKIM.js.map → migrate-v2-V4WN2VVM.js.map} +0 -0
  282. /package/dist/{program-SAUNRCCD.js.map → processes-WU23U2OJ.js.map} +0 -0
  283. /package/dist/{providers-L27BJ5A6.js.map → program-GSZ5AYAM.js.map} +0 -0
  284. /package/dist/{projects-api-KRME2YKZ.js.map → projects-api-QE6L65SK.js.map} +0 -0
  285. /package/dist/{projects-mutate-3NHVG437.js.map → projects-mutate-MY7JTLSZ.js.map} +0 -0
  286. /package/dist/{query-HZYYRCLU.js.map → providers-KO2DODIO.js.map} +0 -0
  287. /package/dist/{recipes-EPLE2SNT.js.map → query-SQDHE4HV.js.map} +0 -0
  288. /package/dist/{registry-HK3WQ7BS.js.map → recipes-7S4MC5OJ.js.map} +0 -0
  289. /package/dist/{runner-BFYDWPUO.js.map → registry-LE6FWYZC.js.map} +0 -0
  290. /package/dist/{rotation-memo-B3EXXSEH.js.map → rotation-memo-7T75BLEX.js.map} +0 -0
  291. /package/dist/{scheduler-6T2SU5ST.js.map → routing-ILERJXB6.js.map} +0 -0
  292. /package/dist/{store-I3HEWVLQ.js.map → runner-YRBVNYTY.js.map} +0 -0
  293. /package/dist/{surface-usage-PRL6Z4U3.js.map → scheduler-BFM3EINS.js.map} +0 -0
  294. /package/dist/{timers-5LPZN55J.js.map → seat-7WM7II2B.js.map} +0 -0
  295. /package/dist/{types-VECTMYTO.js.map → store-MCOA6N4F.js.map} +0 -0
  296. /package/dist/{subagent-WV7QKQ3L.js.map → subagent-2SJHSSJS.js.map} +0 -0
  297. /package/dist/{types-Y6ZCZ52Z.js.map → surface-usage-NS74AXSE.js.map} +0 -0
  298. /package/dist/{wiki-M6YNRFC4.js.map → timers-PCB4ZIBD.js.map} +0 -0
  299. /package/dist/{workspace-setup-TBQAHYHJ.js.map → types-H5ENIUIT.js.map} +0 -0
  300. /package/dist/{whatsapp-state-XMTCSRXU.js.map → whatsapp-state-6D6FHHD2.js.map} +0 -0
@@ -0,0 +1,179 @@
1
+ import AppKit
2
+
3
+ /// A callout that stays on screen and updates while the agent talks.
4
+ ///
5
+ /// The pointer's label vanishes with its highlight, which is fine for
6
+ /// "here is the button" and useless for "here is what we are doing and
7
+ /// why". A lesson needs somewhere the current instruction persists —
8
+ /// something you can glance back at after listening, and that still says
9
+ /// what is happening when the speech has moved on.
10
+ ///
11
+ /// Driven over stdin rather than as one-shot calls, because the point is
12
+ /// that it UPDATES: a fresh window per step would flash, lose its
13
+ /// position, and steal focus. One window, many lines, closed on EOF.
14
+ ///
15
+ /// {"title":"Step 2 of 9","body":"Type from:naval","state":"typing",
16
+ /// "avoid":{"x":218,"y":145,"w":420,"h":40}}
17
+ ///
18
+ /// `avoid` is the rectangle the lesson is about to point at. The callout
19
+ /// moves out of its way.
20
+ ///
21
+ /// This is not the same problem as click occlusion, and a hit test does
22
+ /// not catch it: the panel sets ignoresMouseEvents, so a click passes
23
+ /// straight through and the window server correctly reports whatever is
24
+ /// underneath. It is invisible to a click and very visible to a person —
25
+ /// which is exactly how this window ended up sitting on top of the search
26
+ /// field a lesson was pointing at, with nothing able to notice.
27
+ ///
28
+ /// Anything unparseable is ignored rather than fatal — a malformed line
29
+ /// from a caller should not take down the display mid-lesson.
30
+ enum HUD {
31
+
32
+ private final class Window: NSPanel {
33
+ let titleField = NSTextField(labelWithString: "")
34
+ let bodyField = NSTextField(wrappingLabelWithString: "")
35
+ let dot = NSView()
36
+
37
+ init() {
38
+ super.init(contentRect: NSRect(x: 0, y: 0, width: 460, height: 96),
39
+ styleMask: [.borderless, .nonactivatingPanel],
40
+ backing: .buffered, defer: false)
41
+ level = .screenSaver
42
+ isOpaque = false
43
+ backgroundColor = .clear
44
+ hasShadow = true
45
+ ignoresMouseEvents = true
46
+ collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
47
+
48
+ let blur = NSVisualEffectView(frame: NSRect(x: 0, y: 0, width: 460, height: 96))
49
+ blur.material = .hudWindow
50
+ blur.blendingMode = .behindWindow
51
+ blur.state = .active
52
+ blur.wantsLayer = true
53
+ blur.layer?.cornerRadius = Brand.Radius.lg
54
+ blur.layer?.masksToBounds = true
55
+ blur.autoresizingMask = [.width, .height]
56
+ contentView = blur
57
+
58
+ dot.wantsLayer = true
59
+ dot.layer?.cornerRadius = 4
60
+ dot.frame = NSRect(x: 20, y: 63, width: 8, height: 8)
61
+ blur.addSubview(dot)
62
+
63
+ titleField.frame = NSRect(x: 38, y: 58, width: 400, height: 18)
64
+ // Eyebrow: mono, uppercase, tracked — "STEP 2 OF 9".
65
+ titleField.font = .monospacedSystemFont(ofSize: 10, weight: .semibold)
66
+ titleField.textColor = .secondaryLabelColor
67
+ blur.addSubview(titleField)
68
+
69
+ bodyField.frame = NSRect(x: 20, y: 14, width: 420, height: 40)
70
+ bodyField.font = Brand.body(size: 14)
71
+ bodyField.textColor = .labelColor
72
+ bodyField.maximumNumberOfLines = 2
73
+ blur.addSubview(bodyField)
74
+
75
+ moveToDefault()
76
+ }
77
+
78
+ /// Top centre: out of the way of most content, and where a person
79
+ /// already looks for system feedback.
80
+ func moveToDefault() {
81
+ guard let screen = NSScreen.main else { return }
82
+ let v = screen.visibleFrame
83
+ setFrameOrigin(NSPoint(x: v.midX - frame.width / 2, y: v.maxY - frame.height - 34))
84
+ }
85
+
86
+ /// Move below the target when the default position would cover it.
87
+ ///
88
+ /// `rect` arrives in accessibility coordinates (top-left origin);
89
+ /// windows use bottom-left, so it is flipped before comparing.
90
+ func avoid(_ rect: CGRect) {
91
+ guard let screen = NSScreen.main else { return }
92
+ let v = screen.visibleFrame
93
+ let flipped = CGRect(x: rect.origin.x,
94
+ y: screen.frame.maxY - rect.origin.y - rect.height,
95
+ width: rect.width, height: rect.height)
96
+ moveToDefault()
97
+ // A margin, so the callout does not merely touch the target's
98
+ // edge and still crowd it.
99
+ guard frame.insetBy(dx: -16, dy: -16).intersects(flipped) else { return }
100
+
101
+ // Prefer just below the target; fall back to just above when
102
+ // there is no room, and give up rather than push it off screen.
103
+ let below = flipped.minY - frame.height - 20
104
+ if below > v.minY {
105
+ setFrameOrigin(NSPoint(x: frame.origin.x, y: below))
106
+ } else {
107
+ let above = flipped.maxY + 20
108
+ if above + frame.height < v.maxY {
109
+ setFrameOrigin(NSPoint(x: frame.origin.x, y: above))
110
+ }
111
+ }
112
+ }
113
+
114
+ override var canBecomeKey: Bool { false }
115
+ }
116
+
117
+ // Teal for the agent acting on the screen, blue while it speaks,
118
+ // amber when it is blocked. One accent family, not a traffic light.
119
+ private static let colours: [String: NSColor] = [
120
+ "talking": Brand.primaryBright,
121
+ "pointing": Brand.accent,
122
+ "typing": Brand.accentDeep,
123
+ "waiting": Brand.warn,
124
+ "done": Brand.accent,
125
+ ]
126
+
127
+ /// Read update lines until stdin closes.
128
+ static func run() {
129
+ let app = NSApplication.shared
130
+ app.setActivationPolicy(.accessory)
131
+ let window = Window()
132
+ window.alphaValue = 0
133
+ window.orderFrontRegardless()
134
+ NSAnimationContext.runAnimationGroup { $0.duration = 0.2; window.animator().alphaValue = 1 }
135
+
136
+ // stdin on a background thread: the main thread has to keep
137
+ // pumping the run loop or nothing renders and the blur never
138
+ // resolves.
139
+ let queue = DispatchQueue(label: "tn.acme.agentx.hud.stdin")
140
+ queue.async {
141
+ while let line = readLine(strippingNewline: true) {
142
+ guard let data = line.data(using: .utf8),
143
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
144
+ else { continue }
145
+ DispatchQueue.main.async {
146
+ if let t = obj["title"] as? String {
147
+ window.titleField.attributedStringValue =
148
+ Brand.metaString(t, size: 10, color: .secondaryLabelColor)
149
+ }
150
+ if let b = obj["body"] as? String { window.bodyField.stringValue = b }
151
+ if let a = obj["avoid"] as? [String: Any],
152
+ let x = a["x"] as? Double, let y = a["y"] as? Double,
153
+ let w = a["w"] as? Double, let h = a["h"] as? Double {
154
+ window.avoid(CGRect(x: x, y: y, width: w, height: h))
155
+ } else {
156
+ window.moveToDefault()
157
+ }
158
+ let state = (obj["state"] as? String) ?? "talking"
159
+ let c = colours[state] ?? Brand.primaryBright
160
+ window.dot.layer?.backgroundColor = c.cgColor
161
+ window.dot.layer?.shadowColor = c.cgColor
162
+ window.dot.layer?.shadowOpacity = 0.6
163
+ window.dot.layer?.shadowRadius = 5
164
+ window.dot.layer?.shadowOffset = .zero
165
+ }
166
+ }
167
+ // EOF: the lesson ended.
168
+ DispatchQueue.main.async {
169
+ NSAnimationContext.runAnimationGroup { ctx in
170
+ ctx.duration = 0.25
171
+ window.animator().alphaValue = 0
172
+ } completionHandler: {
173
+ app.terminate(nil)
174
+ }
175
+ }
176
+ }
177
+ app.run()
178
+ }
179
+ }
@@ -0,0 +1,117 @@
1
+ import AppKit
2
+ import Vision
3
+
4
+ /// Reading the screen when the accessibility tree has nothing to say.
5
+ ///
6
+ /// Electron apps are the reason this exists. VS Code exposes three
7
+ /// controls and labels none of them; Screen Studio's own recording picker
8
+ /// exposes no children at all. To the tree those screens look empty, which
9
+ /// is indistinguishable from an app with genuinely nothing on it — so a
10
+ /// lesson or an agent asked to act there has no candidates and can only
11
+ /// refuse.
12
+ ///
13
+ /// Apple's Vision framework reads the pixels instead. Entirely local: the
14
+ /// image never leaves the machine, which is the same posture third-hand
15
+ /// takes and the opposite of shipping screenshots to a vision model.
16
+ ///
17
+ /// What this buys and what it does not. OCR returns TEXT AND WHERE IT IS.
18
+ /// It does not return affordances — it cannot tell a button from its own
19
+ /// caption, or a disabled control from an enabled one. So an OCR
20
+ /// candidate is a weaker thing than a tree element and is marked as such,
21
+ /// rather than quietly mixed in as though the two were equivalent.
22
+ enum OCR {
23
+
24
+ struct TextHit: Codable {
25
+ let text: String
26
+ /// Screen coordinates, top-left origin — the same convention the
27
+ /// accessibility tree uses, so callers need no second mapping.
28
+ let x: Double
29
+ let y: Double
30
+ let width: Double
31
+ let height: Double
32
+ let confidence: Double
33
+ }
34
+
35
+ /// Read text from a screen rectangle.
36
+ ///
37
+ /// `rect` is in screen coordinates with a TOP-LEFT origin, matching
38
+ /// AXTree. Everything about coordinates here is a conversion between
39
+ /// three conventions and it is the part most likely to be wrong, so
40
+ /// each step is named:
41
+ ///
42
+ /// 1. CGWindowListCreateImage takes top-left screen coords. Good.
43
+ /// 2. The image comes back at backing scale — 2x on Retina — so its
44
+ /// pixel size is not the rect's point size.
45
+ /// 3. Vision reports NORMALISED coordinates (0…1) with a BOTTOM-LEFT
46
+ /// origin, relative to the image.
47
+ ///
48
+ /// So a hit is un-normalised against the image, flipped vertically,
49
+ /// and finally offset by the rect's own origin.
50
+ static func read(rect: CGRect, languages: [String] = ["fr-FR", "en-US"]) -> [TextHit] {
51
+ guard rect.width >= 8, rect.height >= 8,
52
+ let image = CGWindowListCreateImage(
53
+ rect, .optionOnScreenOnly, kCGNullWindowID, [.bestResolution])
54
+ else { return [] }
55
+
56
+ let request = VNRecognizeTextRequest()
57
+ request.recognitionLevel = .accurate
58
+ // The machine under test is French and pages render in French;
59
+ // an English-only recogniser drops accented words entirely.
60
+ request.recognitionLanguages = languages
61
+ request.usesLanguageCorrection = true
62
+
63
+ let handler = VNImageRequestHandler(cgImage: image, options: [:])
64
+ guard (try? handler.perform([request])) != nil,
65
+ let observations = request.results
66
+ else { return [] }
67
+
68
+ var hits: [TextHit] = []
69
+ for obs in observations {
70
+ guard let candidate = obs.topCandidates(1).first else { continue }
71
+ let text = candidate.string.trimmingCharacters(in: .whitespacesAndNewlines)
72
+ // Single stray characters are noise from borders and icons.
73
+ guard text.count > 1 else { continue }
74
+
75
+ // Vision box: normalised, bottom-left origin, relative to image.
76
+ let b = obs.boundingBox
77
+ let w = b.width * rect.width
78
+ let h = b.height * rect.height
79
+ let x = rect.minX + b.minX * rect.width
80
+ // Flip: Vision's y grows upward from the image bottom; screen
81
+ // coordinates grow downward from the rect top.
82
+ let y = rect.minY + (1.0 - b.maxY) * rect.height
83
+
84
+ hits.append(TextHit(text: text, x: x, y: y, width: w, height: h,
85
+ confidence: Double(candidate.confidence)))
86
+ }
87
+ // Reading order: top to bottom, then left to right. Matches how a
88
+ // person would enumerate what is on screen.
89
+ return hits.sorted { a, b in
90
+ abs(a.y - b.y) > 6 ? a.y < b.y : a.x < b.x
91
+ }
92
+ }
93
+
94
+ /// The focused window's frame, in top-left screen coordinates.
95
+ ///
96
+ /// Capturing the whole screen would work and is worse: more to read,
97
+ /// slower, and it pulls in every other application's content.
98
+ static func focusedWindowFrame() -> CGRect? {
99
+ guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
100
+ let axApp = AXUIElementCreateApplication(app.processIdentifier)
101
+ var windowRef: CFTypeRef?
102
+ guard AXUIElementCopyAttributeValue(axApp, kAXFocusedWindowAttribute as CFString, &windowRef) == .success,
103
+ CFGetTypeID(windowRef) == AXUIElementGetTypeID()
104
+ else { return nil }
105
+ let window = unsafeBitCast(windowRef, to: AXUIElement.self)
106
+
107
+ var posRef: CFTypeRef?, sizeRef: CFTypeRef?
108
+ guard AXUIElementCopyAttributeValue(window, kAXPositionAttribute as CFString, &posRef) == .success,
109
+ AXUIElementCopyAttributeValue(window, kAXSizeAttribute as CFString, &sizeRef) == .success
110
+ else { return nil }
111
+ var p = CGPoint.zero, s = CGSize.zero
112
+ AXValueGetValue(posRef as! AXValue, .cgPoint, &p)
113
+ AXValueGetValue(sizeRef as! AXValue, .cgSize, &s)
114
+ guard s.width > 0, s.height > 0 else { return nil }
115
+ return CGRect(origin: p, size: s)
116
+ }
117
+ }
@@ -0,0 +1,309 @@
1
+ import AppKit
2
+
3
+ /// Shows where a control is, at a speed a person can follow. Does not click.
4
+ ///
5
+ /// The first version warped the cursor, then eased it over a flat 280ms.
6
+ /// Both read as machine motion: a teleport is missed entirely, and a
7
+ /// constant-duration slide is the same speed whether it crosses the screen
8
+ /// or nudges thirty points. Neither looks like a hand.
9
+ ///
10
+ /// Real pointing has three properties worth copying, all cheap:
11
+ ///
12
+ /// 1. Duration follows Fitts's law — far targets and small targets take
13
+ /// longer. A fixed duration is the single biggest tell.
14
+ /// 2. Velocity is minimum-jerk: accelerate, cruise, decelerate. Linear
15
+ /// motion reads as a glitch; abrupt stops read as a snap.
16
+ /// 3. The path bows slightly. Hands do not travel in straight lines, and
17
+ /// a perfectly straight traverse is conspicuous on a long move.
18
+ ///
19
+ /// There is deliberately no click here. The point tier exists so a model's
20
+ /// choice is visible and reviewable BEFORE anything irreversible happens.
21
+ enum Pointer {
22
+
23
+ static func point(x: Double, y: Double, width: Double, height: Double, label: String,
24
+ holdSeconds: Double = 2.5, instant: Bool = false) {
25
+ let target = CGPoint(x: x + width / 2, y: y + height / 2)
26
+ if instant {
27
+ CGWarpMouseCursorPosition(target)
28
+ } else {
29
+ humanMove(to: target, targetSize: min(width, height))
30
+ }
31
+ CGAssociateMouseAndMouseCursorPosition(1)
32
+ annotate(CGRect(x: x, y: y, width: width, height: height),
33
+ label: label, holdSeconds: holdSeconds)
34
+ }
35
+
36
+ // MARK: Movement
37
+
38
+ /// Move the way a hand would.
39
+ private static func humanMove(to target: CGPoint, targetSize: Double) {
40
+ let from = CGEvent(source: nil)?.location ?? target
41
+ let dx = target.x - from.x, dy = target.y - from.y
42
+ let distance = (dx * dx + dy * dy).squareRoot()
43
+ if distance < 2 { CGWarpMouseCursorPosition(target); return }
44
+
45
+ // Fitts's law: time ∝ log2(distance / width + 1). The constants put
46
+ // a short hop near 180ms and a full-screen traverse near 700ms,
47
+ // which is roughly what a person does — fast enough not to feel
48
+ // like waiting, slow enough to follow.
49
+ let width = max(targetSize, 12)
50
+ let difficulty = log2(distance / width + 1)
51
+ let duration = min(0.95, max(0.18, 0.09 + 0.13 * difficulty))
52
+
53
+ // Bow the path perpendicular to the direction of travel, scaled to
54
+ // distance and with a stable sign so it does not wobble.
55
+ let bow = min(distance * 0.08, 55.0) * (dx * dy >= 0 ? 1.0 : -1.0)
56
+ let nx = -dy / distance, ny = dx / distance
57
+ let control = CGPoint(x: from.x + dx * 0.5 + nx * bow,
58
+ y: from.y + dy * 0.5 + ny * bow)
59
+
60
+ let steps = max(24, Int(duration * 110))
61
+ for i in 1...steps {
62
+ let t = Double(i) / Double(steps)
63
+ // Minimum-jerk: 10t³ − 15t⁴ + 6t⁵. Starts and ends at zero
64
+ // velocity AND zero acceleration, which is why it looks settled
65
+ // rather than braked.
66
+ let e = 10 * pow(t, 3) - 15 * pow(t, 4) + 6 * pow(t, 5)
67
+ let p = quadratic(from, control, target, e)
68
+
69
+ // A trace of tremor, fading to nothing as it arrives. Without
70
+ // it the path is mathematically perfect and reads as drawn.
71
+ let tremor = (1 - e) * 0.7
72
+ let jitter = CGPoint(x: Double.random(in: -tremor...tremor),
73
+ y: Double.random(in: -tremor...tremor))
74
+ CGWarpMouseCursorPosition(CGPoint(x: p.x + jitter.x, y: p.y + jitter.y))
75
+ Thread.sleep(forTimeInterval: duration / Double(steps))
76
+ }
77
+
78
+ // Long moves overshoot slightly and correct — the correction is
79
+ // what makes an arrival look human rather than magnetic.
80
+ if distance > 320 {
81
+ let over = CGPoint(x: target.x + dx / distance * 4,
82
+ y: target.y + dy / distance * 4)
83
+ CGWarpMouseCursorPosition(over)
84
+ Thread.sleep(forTimeInterval: 0.045)
85
+ }
86
+ CGWarpMouseCursorPosition(target)
87
+ }
88
+
89
+ private static func quadratic(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint, _ t: Double) -> CGPoint {
90
+ let u = 1 - t
91
+ return CGPoint(x: u * u * a.x + 2 * u * t * b.x + t * t * c.x,
92
+ y: u * u * a.y + 2 * u * t * b.y + t * t * c.y)
93
+ }
94
+
95
+ /// A click, made visible.
96
+ ///
97
+ /// A synthetic click is otherwise invisible — the UI reacts, but
98
+ /// nothing shows that the press came from here rather than from the
99
+ /// person watching. In a lesson that ambiguity is the whole problem:
100
+ /// you cannot learn a gesture you did not see performed.
101
+ ///
102
+ /// So the cursor position gets a ring that snaps inward and rebounds,
103
+ /// which is how a fingertip reads — compression then release, not a
104
+ /// flash. Runs before the event so the visual leads the reaction by a
105
+ /// frame or two, the way a real press does.
106
+ static func clickFlourish(durationSeconds: Double = 0.42) {
107
+ guard let p = CGEvent(source: nil)?.location,
108
+ let screen = NSScreen.screens.first(where: {
109
+ $0.frame.contains(CGPoint(x: p.x, y: $0.frame.maxY - p.y))
110
+ }) ?? NSScreen.main else { return }
111
+
112
+ let r: CGFloat = 46
113
+ let flipped = CGPoint(x: p.x, y: screen.frame.maxY - p.y)
114
+ let panel = NSPanel(contentRect: NSRect(x: flipped.x - r, y: flipped.y - r,
115
+ width: r * 2, height: r * 2),
116
+ styleMask: [.borderless, .nonactivatingPanel],
117
+ backing: .buffered, defer: false)
118
+ panel.level = .screenSaver
119
+ panel.isOpaque = false
120
+ panel.backgroundColor = .clear
121
+ panel.ignoresMouseEvents = true
122
+ panel.hasShadow = false
123
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
124
+
125
+ let host = NSView(frame: panel.contentView!.bounds)
126
+ host.wantsLayer = true
127
+ panel.contentView = host
128
+
129
+ let ring = CAShapeLayer()
130
+ let box = CGRect(x: r - 17, y: r - 17, width: 34, height: 34)
131
+ ring.path = CGPath(ellipseIn: box, transform: nil)
132
+ ring.fillColor = Brand.accent.withAlphaComponent(0.22).cgColor
133
+ ring.strokeColor = Brand.accent.cgColor
134
+ ring.lineWidth = 2.5
135
+ ring.frame = host.bounds
136
+ ring.anchorPoint = CGPoint(x: 0.5, y: 0.5)
137
+ host.layer?.addSublayer(ring)
138
+
139
+ // Squash then rebound past rest, then settle — the shape of a
140
+ // press. A single scale-down reads as the UI shrinking, not as a
141
+ // finger landing.
142
+ let squash = CAKeyframeAnimation(keyPath: "transform.scale")
143
+ squash.values = [1.0, 0.55, 1.22, 1.0]
144
+ squash.keyTimes = [0, 0.28, 0.62, 1.0]
145
+ squash.timingFunctions = [
146
+ CAMediaTimingFunction(name: .easeIn),
147
+ CAMediaTimingFunction(name: .easeOut),
148
+ CAMediaTimingFunction(name: .easeOut),
149
+ ]
150
+ squash.duration = durationSeconds
151
+
152
+ let fade = CAKeyframeAnimation(keyPath: "opacity")
153
+ fade.values = [0.0, 1.0, 0.9, 0.0]
154
+ fade.keyTimes = [0, 0.18, 0.55, 1.0]
155
+ fade.duration = durationSeconds
156
+
157
+ let group = CAAnimationGroup()
158
+ group.animations = [squash, fade]
159
+ group.duration = durationSeconds
160
+ group.isRemovedOnCompletion = false
161
+ group.fillMode = .forwards
162
+ ring.add(group, forKey: "click")
163
+
164
+ panel.orderFrontRegardless()
165
+ let deadline = Date().addingTimeInterval(durationSeconds)
166
+ while Date() < deadline {
167
+ RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.012))
168
+ }
169
+ panel.orderOut(nil)
170
+ }
171
+
172
+ // MARK: Annotation
173
+
174
+ /// A radar ping and a marching-ants border.
175
+ ///
176
+ /// Presentation tools converge on these two for the same reason: an
177
+ /// expanding ring draws the eye to a point without hiding what is
178
+ /// underneath, and a moving dash says "this one" in a way a static box
179
+ /// does not on a busy screen. Both are Core Animation, so they run on
180
+ /// the render thread and stay smooth while the process is otherwise
181
+ /// blocked.
182
+ private static func annotate(_ rect: CGRect, label: String, holdSeconds: Double) {
183
+ guard let screen = NSScreen.screens.first(where: { $0.frame.contains(
184
+ CGPoint(x: rect.midX, y: $0.frame.maxY - rect.midY)) }) ?? NSScreen.main else { return }
185
+
186
+ // Accessibility gives top-left origin; NSWindow wants bottom-left.
187
+ let flipped = CGRect(x: rect.origin.x,
188
+ y: screen.frame.maxY - rect.origin.y - rect.height,
189
+ width: rect.width, height: rect.height)
190
+ let pad: CGFloat = 60 // room for the ping to expand past the target
191
+ let panel = NSPanel(contentRect: flipped.insetBy(dx: -pad, dy: -pad),
192
+ styleMask: [.borderless, .nonactivatingPanel],
193
+ backing: .buffered, defer: false)
194
+ panel.level = .screenSaver
195
+ panel.isOpaque = false
196
+ panel.backgroundColor = .clear
197
+ panel.ignoresMouseEvents = true
198
+ panel.hasShadow = false
199
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
200
+
201
+ let host = NSView(frame: panel.contentView!.bounds)
202
+ host.wantsLayer = true
203
+ panel.contentView = host
204
+
205
+ let box = CGRect(x: pad, y: pad, width: rect.width, height: rect.height)
206
+ let accent = Brand.accent
207
+
208
+ // Marching ants.
209
+ let border = CAShapeLayer()
210
+ border.path = CGPath(roundedRect: box.insetBy(dx: -3, dy: -3),
211
+ cornerWidth: Brand.Radius.base, cornerHeight: Brand.Radius.base, transform: nil)
212
+ border.fillColor = accent.withAlphaComponent(0.10).cgColor
213
+ border.strokeColor = accent.cgColor
214
+ border.lineWidth = 2.5
215
+ border.lineDashPattern = [7, 4]
216
+ host.layer?.addSublayer(border)
217
+
218
+ let ants = CABasicAnimation(keyPath: "lineDashPhase")
219
+ ants.fromValue = 0; ants.toValue = 11
220
+ ants.duration = 0.55
221
+ ants.repeatCount = .infinity
222
+ border.add(ants, forKey: "ants")
223
+
224
+ // Radar ping, twice, offset so the second starts as the first fades.
225
+ for delay in [0.0, 0.5] {
226
+ let ping = CAShapeLayer()
227
+ ping.path = CGPath(roundedRect: box.insetBy(dx: -3, dy: -3),
228
+ cornerWidth: Brand.Radius.base, cornerHeight: Brand.Radius.base, transform: nil)
229
+ ping.fillColor = NSColor.clear.cgColor
230
+ ping.strokeColor = accent.cgColor
231
+ ping.lineWidth = 2
232
+ ping.opacity = 0
233
+ host.layer?.addSublayer(ping)
234
+
235
+ let grow = CABasicAnimation(keyPath: "transform.scale")
236
+ grow.fromValue = 1.0; grow.toValue = 1.9
237
+ let fade = CABasicAnimation(keyPath: "opacity")
238
+ fade.fromValue = 0.85; fade.toValue = 0.0
239
+
240
+ let group = CAAnimationGroup()
241
+ group.animations = [grow, fade]
242
+ group.duration = 1.0
243
+ group.beginTime = CACurrentMediaTime() + delay
244
+ group.repeatCount = 2
245
+ group.timingFunction = CAMediaTimingFunction(name: .easeOut)
246
+ group.isRemovedOnCompletion = false
247
+ // Scale about the target, not the layer's corner.
248
+ ping.anchorPoint = CGPoint(x: 0.5, y: 0.5)
249
+ ping.frame = host.bounds
250
+ ping.add(group, forKey: "ping")
251
+ }
252
+
253
+ if !label.isEmpty { addLabel(label, to: host, above: box, accent: accent) }
254
+
255
+ panel.alphaValue = 0
256
+ panel.orderFrontRegardless()
257
+ NSAnimationContext.runAnimationGroup { ctx in
258
+ ctx.duration = 0.16
259
+ panel.animator().alphaValue = 1
260
+ }
261
+
262
+ // The process is a one-shot CLI: without pumping the run loop the
263
+ // window would never render before it exits.
264
+ let deadline = Date().addingTimeInterval(holdSeconds)
265
+ while Date() < deadline {
266
+ RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02))
267
+ }
268
+ NSAnimationContext.runAnimationGroup { ctx in
269
+ ctx.duration = 0.22
270
+ panel.animator().alphaValue = 0
271
+ }
272
+ let fadeEnd = Date().addingTimeInterval(0.26)
273
+ while Date() < fadeEnd {
274
+ RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02))
275
+ }
276
+ panel.orderOut(nil)
277
+ }
278
+
279
+ /// A pill above the target naming what was chosen, so a wrong pick is
280
+ /// obvious rather than merely mysterious.
281
+ private static func addLabel(_ text: String, to host: NSView, above box: CGRect, accent: NSColor) {
282
+ // Meta type — mono, uppercase, tracked — because this names a
283
+ // thing rather than saying something about it.
284
+ let field = NSTextField(labelWithString: "")
285
+ field.attributedStringValue = Brand.metaString(text, size: 10.5, color: .white)
286
+ field.alignment = .center
287
+ field.sizeToFit()
288
+
289
+ let w = field.frame.width + 18, h = field.frame.height + 8
290
+ // Below the target when there is no room above, so a control near
291
+ // the top of the screen still gets a readable label.
292
+ let above = box.maxY + 8 + h < host.bounds.height
293
+ let y = above ? box.maxY + 8 : box.minY - h - 8
294
+ let pill = NSView(frame: NSRect(x: box.midX - w / 2, y: y, width: w, height: h))
295
+ pill.wantsLayer = true
296
+ pill.layer?.backgroundColor = accent.cgColor
297
+ pill.layer?.cornerRadius = Brand.Radius.sm
298
+ field.frame = NSRect(x: 9, y: 4, width: field.frame.width, height: field.frame.height)
299
+ pill.addSubview(field)
300
+ host.addSubview(pill)
301
+
302
+ let rise = CABasicAnimation(keyPath: "position.y")
303
+ rise.fromValue = (pill.layer?.position.y ?? 0) + (above ? -6 : 6)
304
+ rise.toValue = pill.layer?.position.y ?? 0
305
+ rise.duration = 0.22
306
+ rise.timingFunction = CAMediaTimingFunction(name: .easeOut)
307
+ pill.layer?.add(rise, forKey: "rise")
308
+ }
309
+ }
@@ -0,0 +1,67 @@
1
+ import AppKit
2
+
3
+ /// Display geometry, in the same top-left coordinate space as everything
4
+ /// else in this helper.
5
+ ///
6
+ /// AppKit reports screens in a global space whose origin is the PRIMARY
7
+ /// screen's bottom-left and whose y grows upward. The accessibility tree,
8
+ /// OCR and the pointer all use top-left origins with y growing downward.
9
+ /// Mixing the two puts the cursor on the wrong monitor — and on a single
10
+ /// display it looks correct, so the bug only appears on someone else's
11
+ /// desk. Converting once, here, is cheaper than remembering not to.
12
+ enum Screens {
13
+
14
+ struct Info: Codable {
15
+ let index: Int
16
+ let x: Double
17
+ let y: Double
18
+ let width: Double
19
+ let height: Double
20
+ /// The screen with the menu bar.
21
+ let primary: Bool
22
+ /// The screen the active window is on.
23
+ let active: Bool
24
+ /// 2.0 on Retina. A capture comes back at this multiple of the
25
+ /// point size, which is why anything sent to a model is scaled.
26
+ let scale: Double
27
+ }
28
+
29
+ static func list() -> [Info] {
30
+ let screens = NSScreen.screens
31
+ guard let primary = screens.first else { return [] }
32
+ // Every y flips about the primary screen's top edge.
33
+ let top = primary.frame.maxY
34
+ return screens.enumerated().map { index, screen in
35
+ Info(index: index,
36
+ x: screen.frame.minX,
37
+ y: top - screen.frame.maxY,
38
+ width: screen.frame.width,
39
+ height: screen.frame.height,
40
+ primary: index == 0,
41
+ active: screen == NSScreen.main,
42
+ scale: screen.backingScaleFactor)
43
+ }
44
+ }
45
+
46
+ /// The screen to capture when no rectangle is given: the one holding
47
+ /// the active window, not blindly the primary.
48
+ static func active() -> Info? {
49
+ let all = list()
50
+ return all.first(where: { $0.active }) ?? all.first
51
+ }
52
+
53
+ /// Full bounds of one screen, in top-left coordinates.
54
+ static func rect(_ info: Info) -> CGRect {
55
+ CGRect(x: info.x, y: info.y, width: info.width, height: info.height)
56
+ }
57
+
58
+ /// The menu bar strip of a screen.
59
+ ///
60
+ /// Its own region because it is where macOS puts the truth about
61
+ /// background state: recording indicators, capture pills, sync and VPN
62
+ /// status. None of it is in any app's accessibility tree, and reading
63
+ /// a 24-point strip costs a fraction of a full screen.
64
+ static func menuBar(_ info: Info) -> CGRect {
65
+ CGRect(x: info.x, y: info.y, width: info.width, height: 26)
66
+ }
67
+ }