@m13v/s4l 1.6.197-rc.10

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 (326) hide show
  1. package/README.md +143 -0
  2. package/SKILL.md +342 -0
  3. package/bin/cli.js +980 -0
  4. package/bin/cookie-helper.js +315 -0
  5. package/bin/platform.js +59 -0
  6. package/bin/scheduler/index.js +12 -0
  7. package/bin/scheduler/launchd.js +518 -0
  8. package/browser-agent-configs/all-agents-mcp.json +68 -0
  9. package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
  10. package/browser-agent-configs/linkedin-agent.json +17 -0
  11. package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
  12. package/browser-agent-configs/reddit-agent-mcp.json +16 -0
  13. package/browser-agent-configs/reddit-agent.json +17 -0
  14. package/browser-agent-configs/twitter-harness-mcp.json +18 -0
  15. package/config.example.json +45 -0
  16. package/mcp/dist/index.js +4212 -0
  17. package/mcp/dist/onboarding.js +200 -0
  18. package/mcp/dist/panel.html +176 -0
  19. package/mcp/dist/product-link.html +102 -0
  20. package/mcp/dist/repo.js +222 -0
  21. package/mcp/dist/runtime.js +1079 -0
  22. package/mcp/dist/screencast.js +323 -0
  23. package/mcp/dist/setup.js +545 -0
  24. package/mcp/dist/telemetry.js +306 -0
  25. package/mcp/dist/twitterAuth.js +138 -0
  26. package/mcp/dist/version.js +271 -0
  27. package/mcp/dist/version.json +4 -0
  28. package/mcp/install-runtime.mjs +70 -0
  29. package/mcp/install.mjs +169 -0
  30. package/mcp/manifest.json +80 -0
  31. package/mcp/menubar/dashboard_server.py +213 -0
  32. package/mcp/menubar/s4l_card.py +1336 -0
  33. package/mcp/menubar/s4l_log_relay.py +179 -0
  34. package/mcp/menubar/s4l_menubar.py +2439 -0
  35. package/mcp/menubar/s4l_state.py +891 -0
  36. package/mcp/package.json +34 -0
  37. package/mcp/shared/doctor.cjs +437 -0
  38. package/mcp/shared/onboarding-ledger.cjs +324 -0
  39. package/mcp-servers/browser-harness/server.py +968 -0
  40. package/package.json +160 -0
  41. package/requirements.txt +20 -0
  42. package/scripts/_compute_allowlist.py +58 -0
  43. package/scripts/_db_update.py +20 -0
  44. package/scripts/_filt.py +9 -0
  45. package/scripts/_li_notif_match.py +76 -0
  46. package/scripts/_li_notif_orchestrate.py +126 -0
  47. package/scripts/_lock_preempt_test.py +60 -0
  48. package/scripts/_run_icp_precheck.py +57 -0
  49. package/scripts/a16z_pearx_calendar_reminders.py +99 -0
  50. package/scripts/account_resolver.py +141 -0
  51. package/scripts/active_campaigns.py +114 -0
  52. package/scripts/active_users.py +190 -0
  53. package/scripts/amplitude_24h_signups.py +468 -0
  54. package/scripts/amplitude_signups.py +177 -0
  55. package/scripts/apply_onboarding_selections.py +131 -0
  56. package/scripts/audience_pages.py +243 -0
  57. package/scripts/audit_helper.py +120 -0
  58. package/scripts/author_history_block.py +353 -0
  59. package/scripts/autopilot_stall_watch.py +284 -0
  60. package/scripts/backfill_twitter_attempts_topic.py +81 -0
  61. package/scripts/backfill_twitter_log_post_no_id.py +322 -0
  62. package/scripts/bench_dashboard.sh +138 -0
  63. package/scripts/bh_send.py +39 -0
  64. package/scripts/build_persona.py +409 -0
  65. package/scripts/bulk_icp.py +18 -0
  66. package/scripts/campaign_bump.py +51 -0
  67. package/scripts/capture_thread_media.py +288 -0
  68. package/scripts/check_browser_lock_health.sh +81 -0
  69. package/scripts/check_external_pool_depth.py +253 -0
  70. package/scripts/check_unread_web_chats.py +28 -0
  71. package/scripts/claim_web_chat.py +47 -0
  72. package/scripts/classify_run_error.py +158 -0
  73. package/scripts/claude_job.py +988 -0
  74. package/scripts/clean_stale_singleton.sh +56 -0
  75. package/scripts/cleanup_harness_tabs.py +68 -0
  76. package/scripts/copy_browser_cookies.py +454 -0
  77. package/scripts/counterparty_history.py +350 -0
  78. package/scripts/db.py +57 -0
  79. package/scripts/discover_claude_profiles.py +120 -0
  80. package/scripts/discover_linkedin_candidates.py +984 -0
  81. package/scripts/dm_conversation.py +682 -0
  82. package/scripts/dm_db_update.py +69 -0
  83. package/scripts/dm_engage_helper.py +161 -0
  84. package/scripts/dm_outreach_helper.py +147 -0
  85. package/scripts/dm_outreach_twitter_helper.py +129 -0
  86. package/scripts/dm_send_log.py +106 -0
  87. package/scripts/dm_short_links.py +1084 -0
  88. package/scripts/dump_web_chat_history.py +47 -0
  89. package/scripts/engage_github.py +640 -0
  90. package/scripts/engage_reddit.py +1235 -0
  91. package/scripts/engage_twitter_helper.py +301 -0
  92. package/scripts/engagement_styles.py +1787 -0
  93. package/scripts/enrich_twitter_candidates.py +82 -0
  94. package/scripts/feedback_digest.py +448 -0
  95. package/scripts/fetch_prospect_profile.py +312 -0
  96. package/scripts/fetch_twitter_t1.py +134 -0
  97. package/scripts/find_threads.py +530 -0
  98. package/scripts/follow_gate_log.py +59 -0
  99. package/scripts/funnel_per_day.py +194 -0
  100. package/scripts/generate_daily_human_style.py +494 -0
  101. package/scripts/generation_trace.py +173 -0
  102. package/scripts/get_run_cost.py +107 -0
  103. package/scripts/github_engage_helper.py +93 -0
  104. package/scripts/github_tools.py +509 -0
  105. package/scripts/harness_overlay.py +556 -0
  106. package/scripts/harvest_twitter_following.py +243 -0
  107. package/scripts/heartbeat.sh +70 -0
  108. package/scripts/history_context.py +284 -0
  109. package/scripts/http_api.py +206 -0
  110. package/scripts/human_dm_replies_helper.py +169 -0
  111. package/scripts/identity.py +302 -0
  112. package/scripts/ig_batch_creator.sh +93 -0
  113. package/scripts/ig_post_type_picker.py +243 -0
  114. package/scripts/ig_scrape_transcribe.sh +91 -0
  115. package/scripts/ingest_human_dm_replies.py +271 -0
  116. package/scripts/ingest_web_chat_replies.py +229 -0
  117. package/scripts/install_fleet.py +187 -0
  118. package/scripts/invent_mcp_server.py +350 -0
  119. package/scripts/invent_topics.py +1462 -0
  120. package/scripts/learned_preferences.py +263 -0
  121. package/scripts/li_discovery.py +161 -0
  122. package/scripts/link_edit_helper.py +142 -0
  123. package/scripts/link_tail.py +592 -0
  124. package/scripts/linkedin_api.py +561 -0
  125. package/scripts/linkedin_browser.py +730 -0
  126. package/scripts/linkedin_cooldown.py +128 -0
  127. package/scripts/linkedin_exclusions.py +234 -0
  128. package/scripts/linkedin_killswitch.py +1333 -0
  129. package/scripts/linkedin_search_topic_schema.py +49 -0
  130. package/scripts/linkedin_unipile.py +658 -0
  131. package/scripts/linkedin_url.py +228 -0
  132. package/scripts/log_claude_session.py +636 -0
  133. package/scripts/log_draft.py +143 -0
  134. package/scripts/log_linkedin_search_attempts.py +126 -0
  135. package/scripts/log_post.py +651 -0
  136. package/scripts/log_run.py +364 -0
  137. package/scripts/log_thread_media.py +108 -0
  138. package/scripts/log_twitter_search_attempts.py +150 -0
  139. package/scripts/log_twitter_skips.py +211 -0
  140. package/scripts/lookup_post.py +78 -0
  141. package/scripts/mark_web_chat_processed.py +32 -0
  142. package/scripts/mcp_lock_proxy.py +370 -0
  143. package/scripts/memory_snapshot.py +972 -0
  144. package/scripts/merge_review_queue.py +215 -0
  145. package/scripts/mint_external_pool.py +182 -0
  146. package/scripts/mint_kent_pool.py +249 -0
  147. package/scripts/moltbook_post.py +320 -0
  148. package/scripts/moltbook_tools.py +159 -0
  149. package/scripts/pending_threads.py +188 -0
  150. package/scripts/pick_ig_account.py +177 -0
  151. package/scripts/pick_project.py +208 -0
  152. package/scripts/pick_search_topic.py +771 -0
  153. package/scripts/pick_thread_target.py +279 -0
  154. package/scripts/pick_twitter_thread_target.py +202 -0
  155. package/scripts/podlog_fetch_batch.sh +32 -0
  156. package/scripts/post_github.py +1311 -0
  157. package/scripts/post_reddit.py +2668 -0
  158. package/scripts/precompute_dashboard_stats.py +204 -0
  159. package/scripts/preflight.sh +297 -0
  160. package/scripts/progress.py +88 -0
  161. package/scripts/project_excludes.py +353 -0
  162. package/scripts/project_slugs.py +91 -0
  163. package/scripts/project_stats.py +241 -0
  164. package/scripts/project_stats_json.py +1563 -0
  165. package/scripts/project_topics.py +192 -0
  166. package/scripts/qualified_query_bank.py +436 -0
  167. package/scripts/reap_stale_claude_sessions.py +867 -0
  168. package/scripts/reddit_browser.py +2549 -0
  169. package/scripts/reddit_browser_fetch.py +141 -0
  170. package/scripts/reddit_browser_lock.py +593 -0
  171. package/scripts/reddit_chat_sync.py +710 -0
  172. package/scripts/reddit_query_bank.py +200 -0
  173. package/scripts/reddit_threads_helper.py +151 -0
  174. package/scripts/reddit_tools.py +956 -0
  175. package/scripts/refresh_instagram_tokens.py +280 -0
  176. package/scripts/release-mcpb.sh +513 -0
  177. package/scripts/reply_db.py +334 -0
  178. package/scripts/reply_insert.py +98 -0
  179. package/scripts/reply_risk_digest.py +761 -0
  180. package/scripts/reset-test-machine.sh +602 -0
  181. package/scripts/restore_twitter_session.py +177 -0
  182. package/scripts/ripen_reddit_plan.py +478 -0
  183. package/scripts/run_claude.sh +433 -0
  184. package/scripts/run_moltbook_cycle.py +555 -0
  185. package/scripts/s4l_box_update.sh +226 -0
  186. package/scripts/s4l_channel.py +103 -0
  187. package/scripts/s4l_ctl.sh +75 -0
  188. package/scripts/s4l_env.py +47 -0
  189. package/scripts/saps_activity.py +126 -0
  190. package/scripts/saps_mode.py +328 -0
  191. package/scripts/scan_dm_candidates.py +580 -0
  192. package/scripts/scan_github_replies.py +168 -0
  193. package/scripts/scan_instagram_comments.py +481 -0
  194. package/scripts/scan_moltbook_replies.py +252 -0
  195. package/scripts/scan_pii.py +190 -0
  196. package/scripts/scan_reddit_replies.py +377 -0
  197. package/scripts/scan_twitter_mentions_browser.py +327 -0
  198. package/scripts/scan_twitter_thread_followups.py +299 -0
  199. package/scripts/scan_x_profile.py +384 -0
  200. package/scripts/schedule_state.py +202 -0
  201. package/scripts/scheduled_tasks_snapshot.py +123 -0
  202. package/scripts/score_linkedin_candidates.py +419 -0
  203. package/scripts/score_twitter_candidates.py +718 -0
  204. package/scripts/scrape_linkedin_comment_stats.py +1755 -0
  205. package/scripts/scrape_linkedin_stats_browser.py +52 -0
  206. package/scripts/scrape_reddit_views.py +365 -0
  207. package/scripts/seed_search_queries.py +453 -0
  208. package/scripts/seed_search_topics.py +127 -0
  209. package/scripts/send_web_chat_reply.py +130 -0
  210. package/scripts/sentry_init.py +128 -0
  211. package/scripts/setup_twitter_auth.py +1320 -0
  212. package/scripts/snapshot.py +583 -0
  213. package/scripts/stats.py +2702 -0
  214. package/scripts/stats_helper.py +52 -0
  215. package/scripts/strike_alert.py +783 -0
  216. package/scripts/sweep_post_link_clicks.py +107 -0
  217. package/scripts/sync_ig_to_posts.py +147 -0
  218. package/scripts/test_browser_lock.py +189 -0
  219. package/scripts/test_installation_api.sh +52 -0
  220. package/scripts/test_percard_posting.py +142 -0
  221. package/scripts/top_dud_linkedin_queries.py +71 -0
  222. package/scripts/top_dud_reddit_queries.py +67 -0
  223. package/scripts/top_dud_twitter_queries.py +71 -0
  224. package/scripts/top_dud_twitter_topics.py +102 -0
  225. package/scripts/top_linkedin_queries.py +55 -0
  226. package/scripts/top_omitted_reddit_topics.py +91 -0
  227. package/scripts/top_performers.py +588 -0
  228. package/scripts/top_search_topics.py +180 -0
  229. package/scripts/top_twitter_queries.py +190 -0
  230. package/scripts/twitter_access_check.py +382 -0
  231. package/scripts/twitter_account.py +41 -0
  232. package/scripts/twitter_batch_phase.py +126 -0
  233. package/scripts/twitter_browser.py +2804 -0
  234. package/scripts/twitter_cookie_mirror.py +130 -0
  235. package/scripts/twitter_cycle_helper.py +310 -0
  236. package/scripts/twitter_gen_links.py +287 -0
  237. package/scripts/twitter_post_plan.py +1188 -0
  238. package/scripts/twitter_scan.py +324 -0
  239. package/scripts/twitter_supply_signal.py +57 -0
  240. package/scripts/twitter_threads_helper.py +152 -0
  241. package/scripts/unclaim_web_chat.py +29 -0
  242. package/scripts/update_instagram_stats.py +261 -0
  243. package/scripts/update_linkedin_stats_from_feed.py +328 -0
  244. package/scripts/version.py +72 -0
  245. package/scripts/watchdog_hung_runs.py +343 -0
  246. package/scripts/write_generation_trace.py +73 -0
  247. package/setup/SKILL.md +277 -0
  248. package/skill/amplitude-24h-signups.sh +38 -0
  249. package/skill/archive-old-logs.sh +40 -0
  250. package/skill/audit-dm-staleness.sh +42 -0
  251. package/skill/audit-linkedin.sh +14 -0
  252. package/skill/audit-moltbook.sh +4 -0
  253. package/skill/audit-reddit-resurrect.sh +67 -0
  254. package/skill/audit-reddit.sh +4 -0
  255. package/skill/audit-twitter.sh +4 -0
  256. package/skill/audit.sh +287 -0
  257. package/skill/backfill-twitter-attempts-topic.sh +19 -0
  258. package/skill/backfill-twitter-ghost-posts.sh +24 -0
  259. package/skill/check-external-pool-depth.sh +7 -0
  260. package/skill/check-web-chats.sh +203 -0
  261. package/skill/dm-outreach-linkedin.sh +250 -0
  262. package/skill/dm-outreach-reddit.sh +274 -0
  263. package/skill/dm-outreach-twitter.sh +265 -0
  264. package/skill/engage-dm-replies-linkedin.sh +4 -0
  265. package/skill/engage-dm-replies-reddit.sh +4 -0
  266. package/skill/engage-dm-replies-twitter.sh +4 -0
  267. package/skill/engage-dm-replies.sh +1597 -0
  268. package/skill/engage-linkedin.sh +581 -0
  269. package/skill/engage-moltbook.sh +36 -0
  270. package/skill/engage-reddit.sh +146 -0
  271. package/skill/engage-twitter.sh +467 -0
  272. package/skill/github-engage.sh +176 -0
  273. package/skill/ingest-web-chat-replies.sh +38 -0
  274. package/skill/invent-supply-test.sh +100 -0
  275. package/skill/invent-topics.sh +50 -0
  276. package/skill/lib/linkedin-backend.sh +364 -0
  277. package/skill/lib/platform.sh +48 -0
  278. package/skill/lib/reddit-backend.sh +234 -0
  279. package/skill/lib/twitter-backend.sh +314 -0
  280. package/skill/link-edit-github.sh +136 -0
  281. package/skill/link-edit-moltbook.sh +117 -0
  282. package/skill/link-edit-reddit.sh +201 -0
  283. package/skill/linkedin-presence.sh +182 -0
  284. package/skill/linkedin-recovery.sh +282 -0
  285. package/skill/lock.sh +647 -0
  286. package/skill/memory-snapshot.sh +39 -0
  287. package/skill/precompute-stats.sh +35 -0
  288. package/skill/prewarm-funnel.sh +104 -0
  289. package/skill/refresh-instagram-tokens.sh +57 -0
  290. package/skill/refresh-twitter-following.sh +52 -0
  291. package/skill/reply-risk-digest.sh +31 -0
  292. package/skill/run-cycle-update-guard.sh +44 -0
  293. package/skill/run-draft-and-publish.sh +123 -0
  294. package/skill/run-generate-daily-style.sh +50 -0
  295. package/skill/run-github-launchd.sh +62 -0
  296. package/skill/run-github.sh +102 -0
  297. package/skill/run-instagram-daily.sh +149 -0
  298. package/skill/run-instagram-render.sh +875 -0
  299. package/skill/run-linkedin-launchd.sh +81 -0
  300. package/skill/run-linkedin-unipile.sh +130 -0
  301. package/skill/run-linkedin.sh +1593 -0
  302. package/skill/run-moltbook-launchd.sh +61 -0
  303. package/skill/run-moltbook.sh +38 -0
  304. package/skill/run-overlay-watch.sh +100 -0
  305. package/skill/run-reddit-search-launchd.sh +64 -0
  306. package/skill/run-reddit-search.sh +505 -0
  307. package/skill/run-reddit-threads-double.sh +32 -0
  308. package/skill/run-reddit-threads.sh +847 -0
  309. package/skill/run-scan-moltbook-replies.sh +57 -0
  310. package/skill/run-twitter-cycle-launchd.sh +63 -0
  311. package/skill/run-twitter-cycle-singleton.sh +62 -0
  312. package/skill/run-twitter-cycle.sh +2408 -0
  313. package/skill/run-twitter-threads.sh +592 -0
  314. package/skill/scan-instagram-replies.sh +61 -0
  315. package/skill/scan-twitter-followups.sh +57 -0
  316. package/skill/social-autoposter-update.sh +66 -0
  317. package/skill/stats-instagram.sh +72 -0
  318. package/skill/stats-linkedin.sh +271 -0
  319. package/skill/stats-moltbook.sh +4 -0
  320. package/skill/stats-reddit.sh +4 -0
  321. package/skill/stats-twitter.sh +4 -0
  322. package/skill/stats.sh +521 -0
  323. package/skill/strike-alert.sh +18 -0
  324. package/skill/styles.sh +87 -0
  325. package/skill/sweep-link-clicks.sh +40 -0
  326. package/skill/topics.sh +51 -0
@@ -0,0 +1,1336 @@
1
+ """Corner pop-up review cards for draft approval (AppKit / pyobjc).
2
+
3
+ `present_review(drafts, on_decision, on_complete)` shows one small floating panel
4
+ per draft in the top-right corner: thread context, an EDITABLE reply field, a
5
+ counter, and Reject / Approve. `on_decision` fires the INSTANT each card is
6
+ approved/rejected (so an approved draft can post right away), and `on_complete`
7
+ fires once the last card is decided or the window is closed. The whole AppKit
8
+ surface is isolated behind that one function so the menu bar wiring doesn't
9
+ depend on the windowing details.
10
+
11
+ Decision shape: {"n": int, "approved": bool, "loved": bool, "text": str,
12
+ "edited": bool, "drop_link": bool, "reject_category": str|None,
13
+ "reject_note": str|None, "interactions": [{"type": str, "ts": str}],
14
+ "dwell_ms": int}
15
+
16
+ Approving comes in two strengths: the plain Approve button, and the 😄 button
17
+ right next to it, which approves AND stamps loved=True: the user's "this one
18
+ was a really good pick" signal, which the feedback digest treats as strong
19
+ positive evidence (a plain approve is merely counter-evidence against avoid
20
+ entries).
21
+
22
+ `present_feedback(on_submit)` is the OVERALL-feedback composer: a small
23
+ floating panel with one free-text field, reachable from the card's 💬 button
24
+ and from the menu bar's "Send feedback…" item. It carries guidance not tied to
25
+ any single thread; the menu bar registers a default submit handler via
26
+ `set_feedback_handler` (shipping decision='feedback' review events) so both
27
+ entry points behave identically.
28
+
29
+ Rejecting is a two-step flow: the Reject button swaps the card body for a
30
+ reason picker (three one-tap categories plus Other, with an optional free-text
31
+ note). The category feeds the feedback-digest loop that distills human
32
+ rejections into each project's learned_preferences config block; "Skip" keeps
33
+ the old zero-friction reject (no reason recorded). Link clicks (author profile,
34
+ thread ↗) and per-card dwell time ride along on the decision so the digest can
35
+ infer intent (e.g. profile-checked-then-rejected = author-quality signal) even
36
+ when the reason is skipped.
37
+
38
+ Must be driven on the main thread (the menu bar's rumps timer is on the main
39
+ run loop, so that holds).
40
+ """
41
+
42
+ import datetime
43
+ import json
44
+ import re
45
+ import time
46
+
47
+ import objc
48
+ from Foundation import (
49
+ NSObject,
50
+ NSMakeRect,
51
+ NSAttributedString,
52
+ NSMutableAttributedString,
53
+ NSURL,
54
+ )
55
+ from AppKit import (
56
+ NSApp,
57
+ NSPanel,
58
+ NSButton,
59
+ NSTextField,
60
+ NSTextView,
61
+ NSScrollView,
62
+ NSScreen,
63
+ NSEvent,
64
+ NSWindowOcclusionStateVisible,
65
+ NSColor,
66
+ NSFont,
67
+ NSView,
68
+ NSWorkspace,
69
+ NSBackingStoreBuffered,
70
+ NSFloatingWindowLevel,
71
+ NSApplicationActivationPolicyAccessory,
72
+ NSWindowStyleMaskTitled,
73
+ NSWindowStyleMaskClosable,
74
+ NSWindowStyleMaskUtilityWindow,
75
+ NSLineBreakByWordWrapping,
76
+ NSBezelStyleRounded,
77
+ NSFontAttributeName,
78
+ NSForegroundColorAttributeName,
79
+ NSUnderlineStyleAttributeName,
80
+ NSUnderlineStyleSingle,
81
+ NSLinkAttributeName,
82
+ NSTextAlignmentLeft,
83
+ NSTextAlignmentRight,
84
+ NSImage,
85
+ NSPopover,
86
+ NSPopoverBehaviorApplicationDefined,
87
+ NSViewController,
88
+ NSTrackingArea,
89
+ NSTrackingMouseEnteredAndExited,
90
+ NSTrackingActiveAlways,
91
+ NSEventModifierFlagCommand,
92
+ NSEventModifierFlagShift,
93
+ NSEventModifierFlagDeviceIndependentFlagsMask,
94
+ )
95
+
96
+ # Strong reference to the live controller so pyobjc doesn't GC it mid-review
97
+ # (the classic "button click crashes" footgun).
98
+ _active = None
99
+
100
+ W = 380
101
+ H = 300
102
+ M = 16
103
+ NS_BEZEL_BORDER = 2 # NSBezelBorder
104
+
105
+ # Reject-reason categories, in display order. Tags are 1-based button tags on
106
+ # the reason picker; values must match the review_events.reject_category CHECK
107
+ # constraint server-side (wrong_author | off_topic | bad_draft | other).
108
+ REJECT_REASONS = (
109
+ ("wrong_author", "Wrong author / audience"),
110
+ ("off_topic", "Off-topic thread"),
111
+ ("bad_draft", "Draft doesn't sound right"),
112
+ ("other", "Other"),
113
+ )
114
+
115
+ # Client-side cap on tracked interactions per card (server clips at 50 too).
116
+ MAX_INTERACTIONS = 50
117
+
118
+ # Review-surface state mirrored to the state dir for out-of-process observers
119
+ # (the menu bar watchdog, the dashboard, a debugging session). In the 2026-07-02
120
+ # incident a card sat unseen for 3 hours and NOTHING on disk could distinguish
121
+ # "cards shown and ignored" from "cards never shown"; this file is that record.
122
+ REVIEW_STATE_FILE = "review-state.json"
123
+
124
+
125
+ def _write_review_state(controller=None, last_event=None):
126
+ """Best-effort snapshot of the review surface to review-state.json. Passing
127
+ the controller explicitly matters during _build, when the module-level
128
+ _active has not been assigned yet."""
129
+ try:
130
+ from pathlib import Path
131
+
132
+ import s4l_state
133
+
134
+ c = controller if controller is not None else _active
135
+ state = {"open": False}
136
+ if c is not None and c._panel is not None:
137
+ state = c.status_dict()
138
+ if last_event:
139
+ state["last_event"] = last_event
140
+ state["updated_at"] = datetime.datetime.now(
141
+ datetime.timezone.utc
142
+ ).isoformat()
143
+ p = Path(s4l_state.state_dir()) / REVIEW_STATE_FILE
144
+ p.write_text(json.dumps(state) + "\n")
145
+ except Exception:
146
+ pass
147
+
148
+
149
+ def _mouse_screen():
150
+ """The screen the pointer is on right now, i.e. where the user is actually
151
+ looking. Spawning on mainScreen() placed cards on whichever display last
152
+ held key focus, which on a multi-monitor Mac can be a corner the user never
153
+ checks. Falls back to mainScreen when the pointer is between screens."""
154
+ try:
155
+ loc = NSEvent.mouseLocation()
156
+ for s in NSScreen.screens():
157
+ f = s.frame()
158
+ if (
159
+ f.origin.x <= loc.x <= f.origin.x + f.size.width
160
+ and f.origin.y <= loc.y <= f.origin.y + f.size.height
161
+ ):
162
+ return s
163
+ except Exception:
164
+ pass
165
+ return NSScreen.mainScreen()
166
+
167
+
168
+ def _corner_frame(screen):
169
+ """Top-right corner frame for the card on the given screen."""
170
+ vf = (
171
+ screen.visibleFrame()
172
+ if screen is not None
173
+ else NSMakeRect(0, 0, 1440, 900)
174
+ )
175
+ x = vf.origin.x + vf.size.width - W - M
176
+ y = vf.origin.y + vf.size.height - H - M
177
+ return NSMakeRect(x, y, W, H)
178
+
179
+
180
+ class _ReviewPanel(NSPanel):
181
+ """A status-bar app panel that can actually own text input."""
182
+
183
+ def canBecomeKeyWindow(self):
184
+ return True
185
+
186
+ def canBecomeMainWindow(self):
187
+ return True
188
+
189
+ def performKeyEquivalent_(self, event):
190
+ # A rumps (status-bar) app has no Edit menu, so Cmd+V/C/X/A/Z have no
191
+ # menu item to dispatch to and AppKit silently drops them; every text
192
+ # field in this panel (reply editor, reject-reason note) was therefore
193
+ # un-pasteable. Route the standard editing actions down the responder
194
+ # chain ourselves.
195
+ flags = event.modifierFlags() & NSEventModifierFlagDeviceIndependentFlagsMask
196
+ if flags & NSEventModifierFlagCommand:
197
+ key = (event.charactersIgnoringModifiers() or "").lower()
198
+ shift = bool(flags & NSEventModifierFlagShift)
199
+ sel = {
200
+ "v": "paste:",
201
+ "c": "copy:",
202
+ "x": "cut:",
203
+ "a": "selectAll:",
204
+ "z": "redo:" if shift else "undo:",
205
+ }.get(key)
206
+ if sel is not None and NSApp.sendAction_to_from_(sel, None, self):
207
+ return True
208
+ return objc.super(_ReviewPanel, self).performKeyEquivalent_(event)
209
+
210
+
211
+ def _log(msg):
212
+ """Stderr breadcrumb in the menubar.err.log style; card interactions are
213
+ otherwise invisible when debugging a remote box."""
214
+ import sys
215
+
216
+ print(f"[s4l-card] {msg}", file=sys.stderr, flush=True)
217
+
218
+
219
+ def _truncate(s, n=320):
220
+ s = (s or "").strip()
221
+ return s if len(s) <= n else s[: n - 1] + "…"
222
+
223
+
224
+ def _fmt_count(n):
225
+ """1234 -> '1.2k', 3400000 -> '3.4M'; None/garbage -> None (omit the stat)."""
226
+ try:
227
+ n = int(n)
228
+ except (TypeError, ValueError):
229
+ return None
230
+ if n >= 1_000_000:
231
+ s = f"{n / 1_000_000:.1f}M"
232
+ return s.replace(".0M", "M")
233
+ if n >= 1_000:
234
+ s = f"{n / 1_000:.1f}k"
235
+ return s.replace(".0k", "k")
236
+ return str(n)
237
+
238
+
239
+ def _followers_str(stats):
240
+ """Author followers (profile stat), shown INLINE next to the handle in the
241
+ author row. None when the pipeline didn't capture it."""
242
+ followers = _fmt_count((stats or {}).get("author_followers"))
243
+ return None if followers is None else f"{followers} followers"
244
+
245
+
246
+ def _engagement_line(stats):
247
+ """The thread's engagement counts, shown ONLY in the eye icon's hover/click
248
+ popover, never inline on the card. Fields the pipeline didn't capture are
249
+ omitted; returns '' when nothing is known (the eye icon is skipped then)."""
250
+ stats = stats or {}
251
+ parts = []
252
+ for key, label in (
253
+ ("likes", "likes"),
254
+ ("retweets", "reposts"),
255
+ ("replies", "replies"),
256
+ ("views", "views"),
257
+ ):
258
+ v = _fmt_count(stats.get(key))
259
+ if v is not None:
260
+ parts.append(f"{v} {label}")
261
+ return " · ".join(parts)
262
+
263
+
264
+ def _age_str(iso):
265
+ """Thread age since tweet_posted_at, minute-granular for fresh threads
266
+ ('38m'); rolls to hours/days only when minutes would be absurd."""
267
+ if not iso:
268
+ return None
269
+ try:
270
+ t = datetime.datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
271
+ if t.tzinfo is None:
272
+ t = t.replace(tzinfo=datetime.timezone.utc)
273
+ mins = int(
274
+ (datetime.datetime.now(datetime.timezone.utc) - t).total_seconds() // 60
275
+ )
276
+ except Exception:
277
+ return None
278
+ mins = max(mins, 0)
279
+ if mins < 100:
280
+ return f"{mins}m"
281
+ hours = mins // 60
282
+ if hours < 48:
283
+ return f"{hours}h"
284
+ return f"{hours // 24}d"
285
+
286
+
287
+ def _label(frame, text, *, size=12, bold=False, muted=False):
288
+ f = NSTextField.alloc().initWithFrame_(frame)
289
+ f.setStringValue_(text or "")
290
+ f.setBezeled_(False)
291
+ f.setDrawsBackground_(False)
292
+ f.setEditable_(False)
293
+ f.setSelectable_(False)
294
+ f.setFont_(
295
+ NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
296
+ )
297
+ if muted:
298
+ f.setTextColor_(NSColor.secondaryLabelColor())
299
+ f.setLineBreakMode_(NSLineBreakByWordWrapping)
300
+ try:
301
+ f.cell().setWraps_(True)
302
+ f.cell().setScrollable_(False)
303
+ except Exception:
304
+ pass
305
+ return f
306
+
307
+
308
+ class _ReviewController(NSObject):
309
+ def initWithDrafts_onDecision_onComplete_(self, drafts, on_decision, on_complete):
310
+ self = objc.super(_ReviewController, self).init()
311
+ if self is None:
312
+ return None
313
+ self._drafts = list(drafts)
314
+ self._on_decision = on_decision
315
+ self._on_complete = on_complete
316
+ self._idx = 0
317
+ self._decisions = []
318
+ self._panel = None
319
+ self._textview = None
320
+ self._link_targets = {}
321
+ self._eye_btn = None
322
+ self._stats_popover = None
323
+ # Per-card telemetry, reset when a NEW card renders (not on the
324
+ # card <-> reason-picker swap, which is the same card).
325
+ self._rendered_idx = -1
326
+ self._interactions = []
327
+ self._card_shown_at = None
328
+ self._reason_field = None
329
+ # Attention anchors for the unattended-review watchdog: the stack counts
330
+ # as "touched" on present, on any tracked interaction, and on any
331
+ # decision. No touch past the watchdog threshold = the user is not
332
+ # seeing this window, wherever AppKit thinks it is.
333
+ self._presented_at = time.time()
334
+ self._last_decision_at = None
335
+ self._last_interaction_at = None
336
+ self._last_move_log = 0.0
337
+ self._build()
338
+ return self
339
+
340
+ @objc.python_method
341
+ def _track(self, kind):
342
+ """Append one interaction breadcrumb for the CURRENT card. Rides on the
343
+ decision dict so the feedback loop can correlate behavior (opened the
344
+ author profile, read the thread) with the eventual approve/reject."""
345
+ self._last_interaction_at = time.time()
346
+ if len(self._interactions) >= MAX_INTERACTIONS:
347
+ return
348
+ self._interactions.append(
349
+ {
350
+ "type": kind,
351
+ "ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
352
+ }
353
+ )
354
+ _log(f"interaction {kind} (card {self._idx + 1})")
355
+
356
+ @objc.python_method
357
+ def _dwell_ms(self):
358
+ if not self._card_shown_at:
359
+ return None
360
+ return int((time.time() - self._card_shown_at) * 1000)
361
+
362
+ @objc.python_method
363
+ def _occlusion_visible(self):
364
+ """True when macOS is drawing at least one pixel of the card (not fully
365
+ covered, not on an inactive Space, not minimized). None if unknowable."""
366
+ try:
367
+ return bool(
368
+ self._panel.occlusionState() & NSWindowOcclusionStateVisible
369
+ )
370
+ except Exception:
371
+ return None
372
+
373
+ @objc.python_method
374
+ def status_dict(self):
375
+ """Snapshot for the watchdog and review-state.json: is a card open, how
376
+ many drafts are undecided, when it was last touched, where it is, and
377
+ whether the user could physically see it."""
378
+ frame = None
379
+ try:
380
+ fr = self._panel.frame()
381
+ frame = [
382
+ int(fr.origin.x),
383
+ int(fr.origin.y),
384
+ int(fr.size.width),
385
+ int(fr.size.height),
386
+ ]
387
+ except Exception:
388
+ pass
389
+ screen_name = None
390
+ try:
391
+ scr = self._panel.screen()
392
+ if scr is not None:
393
+ screen_name = str(scr.localizedName())
394
+ except Exception:
395
+ pass
396
+ return {
397
+ "open": self._panel is not None,
398
+ "total": len(self._drafts),
399
+ "pending": max(0, len(self._drafts) - self._idx),
400
+ "decided": len(self._decisions),
401
+ "presented_at": self._presented_at,
402
+ "last_decision_at": self._last_decision_at,
403
+ "last_interaction_at": self._last_interaction_at,
404
+ "occlusion_visible": self._occlusion_visible(),
405
+ "frame": frame,
406
+ "screen": screen_name,
407
+ }
408
+
409
+ @objc.python_method
410
+ def _log_surface(self, event):
411
+ """One stderr line + a review-state.json refresh per surface event
412
+ (presented / moved / occlusion_changed / extended / decision / healed).
413
+ This is the positive confirmation layer: silence in the log used to be
414
+ ambiguous between "being reviewed" and "invisible for hours"."""
415
+ s = self.status_dict()
416
+ _log(
417
+ f"{event}: {s['pending']} pending of {s['total']}, "
418
+ f"frame={s['frame']} screen={s['screen']} "
419
+ f"visible={s['occlusion_visible']}"
420
+ )
421
+ _write_review_state(controller=self, last_event=event)
422
+
423
+ def windowDidMove_(self, notification):
424
+ # Timestamped move history. The unanswerable question of the 2026-07-02
425
+ # incident (how did the card end up at the bottom edge of a side
426
+ # display) becomes greppable. Drags fire this repeatedly; throttle.
427
+ now = time.time()
428
+ if now - self._last_move_log < 1.0:
429
+ return
430
+ self._last_move_log = now
431
+ self._log_surface("moved")
432
+
433
+ def windowDidChangeOcclusionState_(self, notification):
434
+ self._log_surface("occlusion_changed")
435
+
436
+ @objc.python_method
437
+ def _build(self):
438
+ frame = _corner_frame(_mouse_screen())
439
+ style = (
440
+ NSWindowStyleMaskTitled
441
+ | NSWindowStyleMaskClosable
442
+ | NSWindowStyleMaskUtilityWindow
443
+ )
444
+ panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
445
+ frame, style, NSBackingStoreBuffered, False
446
+ )
447
+ panel.setLevel_(NSFloatingWindowLevel)
448
+ panel.setFloatingPanel_(True)
449
+ panel.setBecomesKeyOnlyIfNeeded_(False) # let the reply field be edited
450
+ panel.setHidesOnDeactivate_(False)
451
+ panel.setReleasedWhenClosed_(False)
452
+ panel.setDelegate_(self)
453
+ self._panel = panel
454
+ self._render()
455
+ panel.makeKeyAndOrderFront_(None)
456
+ panel.orderFrontRegardless()
457
+ self._log_surface("presented")
458
+ self.focusReply_(None)
459
+ # App activation/key-window promotion lands on the run loop; do one
460
+ # deferred pass so the first card is editable even when opened from the
461
+ # status-bar timer while another app is frontmost.
462
+ self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
463
+
464
+ def focusReply_(self, sender):
465
+ panel = self._panel
466
+ tv = self._textview
467
+ if panel is None or tv is None:
468
+ return
469
+ # A launchd/rumps status item can show a window while remaining a
470
+ # non-activating process. Accessory policy keeps it out of the Dock but
471
+ # lets the review panel become the key/main recipient for NSTextView input.
472
+ try:
473
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
474
+ except Exception:
475
+ pass
476
+ try:
477
+ NSApp.activateIgnoringOtherApps_(True)
478
+ except Exception:
479
+ pass
480
+ try:
481
+ panel.makeKeyAndOrderFront_(None)
482
+ panel.orderFrontRegardless()
483
+ panel.makeMainWindow()
484
+ except Exception:
485
+ pass
486
+ try:
487
+ tv.setEditable_(True)
488
+ tv.setSelectable_(True)
489
+ panel.makeFirstResponder_(tv)
490
+ except Exception:
491
+ pass
492
+
493
+ @objc.python_method
494
+ def _render(self):
495
+ d = self._drafts[self._idx]
496
+ # Fresh card (not a card <-> reason-picker swap): reset telemetry.
497
+ if self._rendered_idx != self._idx:
498
+ self._rendered_idx = self._idx
499
+ self._interactions = []
500
+ self._card_shown_at = time.time()
501
+ self._reason_field = None
502
+ content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
503
+
504
+ # Buttons at the TOP, one line: Approve, 😄 (approve + loved), then a
505
+ # small 💬 (overall feedback, decides nothing), Reject at the right.
506
+ approve = NSButton.alloc().initWithFrame_(NSMakeRect(M, H - 42, 96, 30))
507
+ approve.setTitle_("Approve")
508
+ approve.setBezelStyle_(NSBezelStyleRounded)
509
+ approve.setTarget_(self)
510
+ approve.setAction_("approve:")
511
+ content.addSubview_(approve)
512
+
513
+ # 😄 = approve with the "really good one" signal (loved=True on the
514
+ # decision). Same posting path as Approve; only the feedback rail sees
515
+ # the difference.
516
+ smile = NSButton.alloc().initWithFrame_(NSMakeRect(M + 100, H - 42, 44, 30))
517
+ smile.setTitle_("😄")
518
+ smile.setBezelStyle_(NSBezelStyleRounded)
519
+ smile.setTarget_(self)
520
+ smile.setAction_("approveLoved:")
521
+ try:
522
+ smile.setToolTip_("Approve and mark as a really good pick")
523
+ except Exception:
524
+ pass
525
+ content.addSubview_(smile)
526
+
527
+ # 💬 = overall feedback (about the pipeline, not this draft). Opens the
528
+ # composer panel next to the card; the card itself stays undecided.
529
+ fb = NSButton.alloc().initWithFrame_(NSMakeRect(M + 184, H - 41, 30, 28))
530
+ fb.setTitle_("💬")
531
+ fb.setBordered_(False)
532
+ fb.setTarget_(self)
533
+ fb.setAction_("feedbackOpen:")
534
+ try:
535
+ fb.setToolTip_("Send overall feedback (not about this draft)")
536
+ except Exception:
537
+ pass
538
+ content.addSubview_(fb)
539
+
540
+ reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 96, H - 42, 96, 30))
541
+ reject.setTitle_("Reject")
542
+ reject.setBezelStyle_(NSBezelStyleRounded)
543
+ reject.setTarget_(self)
544
+ reject.setAction_("reject:")
545
+ content.addSubview_(reject)
546
+
547
+ # "Replying to @author" row: the handle is a live link to the author's
548
+ # profile, with the follower count muted inline right after it. Right
549
+ # side: thread age (muted, minutes) and an eye icon whose hover/click
550
+ # popover carries the thread's engagement counts — those are never
551
+ # inline on the card. All from data the pipeline already carries; no
552
+ # scraping happens here.
553
+ self._link_targets = {}
554
+ handle = (d.get("thread_author") or "").lstrip("@").strip()
555
+ stats = d.get("stats") or {}
556
+ thread_url = d.get("thread_url")
557
+ content.addSubview_(
558
+ _label(NSMakeRect(M, H - 70, 78, 18), "Replying to", size=12, bold=True)
559
+ )
560
+ right_x = W - M
561
+ self._close_stats_popover()
562
+ self._eye_btn = None
563
+ if _engagement_line(stats):
564
+ eye = NSButton.alloc().initWithFrame_(NSMakeRect(right_x - 20, H - 70, 20, 18))
565
+ eye.setBordered_(False)
566
+ img = NSImage.imageWithSystemSymbolName_accessibilityDescription_(
567
+ "eye", "thread stats"
568
+ )
569
+ if img is not None:
570
+ eye.setImage_(img)
571
+ eye.setTitle_("")
572
+ else: # pre-Big Sur fallback: no SF Symbols
573
+ eye.setTitle_("👁")
574
+ # Stats surface in an NSPopover on hover OR click. A plain toolTip
575
+ # was tried first and never fired: this panel belongs to a
576
+ # non-activating accessory (status bar) app, where AppKit's tooltip
577
+ # machinery is unreliable. The tracking area drives hover; the
578
+ # button action covers click and any hover-tracking edge case.
579
+ eye.setTarget_(self)
580
+ eye.setAction_("statsToggle:")
581
+ eye.addTrackingArea_(
582
+ NSTrackingArea.alloc().initWithRect_options_owner_userInfo_(
583
+ eye.bounds(),
584
+ NSTrackingMouseEnteredAndExited | NSTrackingActiveAlways,
585
+ self,
586
+ None,
587
+ )
588
+ )
589
+ content.addSubview_(eye)
590
+ self._eye_btn = eye
591
+ right_x -= 24
592
+ age = _age_str(stats.get("tweet_posted_at"))
593
+ if age:
594
+ age_w = int(
595
+ NSAttributedString.alloc().initWithString_attributes_(
596
+ age, {NSFontAttributeName: NSFont.systemFontOfSize_(11)}
597
+ ).size().width
598
+ ) + 8
599
+ age_label = _label(
600
+ NSMakeRect(right_x - age_w, H - 70, age_w, 18), age, size=11, muted=True
601
+ )
602
+ age_label.setAlignment_(NSTextAlignmentRight)
603
+ content.addSubview_(age_label)
604
+ right_x -= age_w + 4
605
+ handle_w = right_x - (M + 78) - 4
606
+ if handle:
607
+ # Size the link to its text so the follower count can sit right
608
+ # after the handle instead of at a fixed column.
609
+ text = f"@{handle}"
610
+ measured = NSAttributedString.alloc().initWithString_attributes_(
611
+ text, {NSFontAttributeName: NSFont.boldSystemFontOfSize_(12)}
612
+ ).size().width
613
+ link_w = min(int(measured) + 8, handle_w)
614
+ self._add_link(
615
+ content,
616
+ NSMakeRect(M + 78, H - 70, link_w, 18),
617
+ text,
618
+ f"https://x.com/{handle}",
619
+ bold=True,
620
+ kind="profile_click",
621
+ )
622
+ followers = _followers_str(stats)
623
+ fol_w = handle_w - link_w
624
+ if followers and fol_w > 20:
625
+ content.addSubview_(
626
+ _label(
627
+ NSMakeRect(M + 78 + link_w, H - 70, fol_w, 18),
628
+ f"· {followers}",
629
+ size=11,
630
+ muted=True,
631
+ )
632
+ )
633
+ else:
634
+ content.addSubview_(
635
+ _label(NSMakeRect(M + 78, H - 70, handle_w, 18), "thread", size=12, bold=True)
636
+ )
637
+ # Thread text — black, with a small trailing ↗ link that opens the
638
+ # thread (an NSTextView because NSTextField can't do clickable links).
639
+ thread_tv = NSTextView.alloc().initWithFrame_(
640
+ NSMakeRect(M, H - 150, W - 2 * M, 74)
641
+ )
642
+ thread_tv.setEditable_(False)
643
+ thread_tv.setSelectable_(True) # links only respond when selectable
644
+ thread_tv.setDrawsBackground_(False)
645
+ # An NSTextView grows vertically by default; long threads inflated the
646
+ # frame over the author row above (non-flipped superview: growth goes
647
+ # UP) and pushed the trailing ↗ out of the box. Pin the frame and
648
+ # truncate to what 4 lines actually fit so the arrow stays visible.
649
+ thread_tv.setVerticallyResizable_(False)
650
+ thread_tv.setHorizontallyResizable_(False)
651
+ body = NSMutableAttributedString.alloc().initWithString_attributes_(
652
+ _truncate(d.get("thread_text"), 200),
653
+ {
654
+ NSFontAttributeName: NSFont.systemFontOfSize_(12),
655
+ NSForegroundColorAttributeName: NSColor.labelColor(),
656
+ },
657
+ )
658
+ if thread_url:
659
+ body.appendAttributedString_(
660
+ NSAttributedString.alloc().initWithString_attributes_(
661
+ " ↗",
662
+ {
663
+ NSFontAttributeName: NSFont.systemFontOfSize_(12),
664
+ # Delegate (textView:clickedOnLink:atIndex:) tracks the
665
+ # click as a thread_click interaction, then opens the
666
+ # URL itself via NSWorkspace.
667
+ NSLinkAttributeName: NSURL.URLWithString_(thread_url),
668
+ },
669
+ )
670
+ )
671
+ thread_tv.setDelegate_(self)
672
+ thread_tv.textStorage().setAttributedString_(body)
673
+ content.addSubview_(thread_tv)
674
+ # Reply heading — black.
675
+ content.addSubview_(
676
+ _label(
677
+ NSMakeRect(M, H - 172, W - 2 * M, 16),
678
+ "Reply (editable):",
679
+ size=12,
680
+ bold=True,
681
+ )
682
+ )
683
+
684
+ # Editable reply, with the attached link folded in as it'll post (no
685
+ # separate link field). The trailing link is stripped on send so the
686
+ # pipeline still mints the tracked /r/<code> short link (no double link).
687
+ reply = d.get("reply_text") or ""
688
+ link = d.get("link_url")
689
+ composed = f"{reply} {link}" if link else reply
690
+ scroll = NSScrollView.alloc().initWithFrame_(
691
+ NSMakeRect(M, M, W - 2 * M, H - 172 - M - 6)
692
+ )
693
+ scroll.setHasVerticalScroller_(True)
694
+ scroll.setBorderType_(NS_BEZEL_BORDER)
695
+ tv = NSTextView.alloc().initWithFrame_(NSMakeRect(0, 0, W - 2 * M, 100))
696
+ tv.setFont_(NSFont.systemFontOfSize_(12))
697
+ tv.setRichText_(False)
698
+ tv.setEditable_(True)
699
+ tv.setSelectable_(True)
700
+ tv.setString_(composed)
701
+ scroll.setDocumentView_(tv)
702
+ content.addSubview_(scroll)
703
+ self._textview = tv
704
+
705
+ self._panel.setContentView_(content)
706
+ # Counter lives in the native title bar, not inside the content.
707
+ self._panel.setTitle_(f"Review draft {self._idx + 1} of {len(self._drafts)}")
708
+ # setContentView_ rebuilds the view tree, so the caret would otherwise
709
+ # default to the Approve button. Re-seat it in the reply field for every
710
+ # card (not just the first) so each one is immediately editable.
711
+ self._panel.makeFirstResponder_(tv)
712
+ self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
713
+
714
+ @objc.python_method
715
+ def _close_stats_popover(self):
716
+ try:
717
+ if self._stats_popover is not None and self._stats_popover.isShown():
718
+ self._stats_popover.close()
719
+ _log("stats popover closed")
720
+ except Exception:
721
+ pass
722
+ self._stats_popover = None
723
+
724
+ @objc.python_method
725
+ def _show_stats_popover(self):
726
+ if self._eye_btn is None:
727
+ return
728
+ if self._stats_popover is not None and self._stats_popover.isShown():
729
+ return
730
+ line = _engagement_line(self._drafts[self._idx].get("stats"))
731
+ if not line:
732
+ return
733
+ font = NSFont.systemFontOfSize_(12)
734
+ s = NSAttributedString.alloc().initWithString_attributes_(
735
+ line, {NSFontAttributeName: font}
736
+ )
737
+ # +34: 13px side insets plus NSTextField's own ~4px internal padding,
738
+ # which otherwise clips the last word.
739
+ pw, ph = int(s.size().width) + 34, 34
740
+ view = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, pw, ph))
741
+ view.addSubview_(_label(NSMakeRect(13, ph - 26, pw - 26, 18), line, size=12))
742
+ vc = NSViewController.alloc().init()
743
+ vc.setView_(view)
744
+ pop = NSPopover.alloc().init()
745
+ # ApplicationDefined, NOT Transient: a transient popover auto-closes
746
+ # whenever the owning app is inactive, and this accessory (status bar)
747
+ # app usually is — on the box the popover opened and dismissed within
748
+ # the same click. We own every close path instead (hover-out, click
749
+ # toggle, card advance, window close).
750
+ pop.setBehavior_(NSPopoverBehaviorApplicationDefined)
751
+ pop.setContentViewController_(vc)
752
+ pop.setContentSize_((pw, ph))
753
+ try:
754
+ NSApp.activateIgnoringOtherApps_(True)
755
+ except Exception:
756
+ pass
757
+ # Anchor to the eye's frame in the (non-flipped) content view, where
758
+ # NSRectEdge 1 = NSMinYEdge is unambiguously the BOTTOM edge, so the
759
+ # popover reliably opens below the icon. Anchoring to the button's own
760
+ # bounds flips the edge meaning (NSButton is a flipped view) and the
761
+ # popover appeared above the card's title bar.
762
+ pop.showRelativeToRect_ofView_preferredEdge_(
763
+ self._eye_btn.frame(), self._eye_btn.superview(), 1
764
+ )
765
+ self._stats_popover = pop
766
+ _log(f"stats popover shown ({pw}x{ph})")
767
+
768
+ # Click on the eye SHOWS the popover, never toggles it closed: a click is
769
+ # physically preceded by hover (mouseEntered already opened it), so a
770
+ # toggle would close what the hover just opened and the user sees nothing.
771
+ # Closing is owned by hover-out, card advance, and window close.
772
+ def statsToggle_(self, sender):
773
+ _log("eye clicked")
774
+ self._track("stats_open")
775
+ self._show_stats_popover()
776
+
777
+ # NSTrackingArea owner callbacks (hover over the eye icon).
778
+ def mouseEntered_(self, event):
779
+ _log("eye hover enter")
780
+ self._show_stats_popover()
781
+
782
+ def mouseExited_(self, event):
783
+ _log("eye hover exit")
784
+ self._close_stats_popover()
785
+
786
+ @objc.python_method
787
+ def _add_link(self, content, frame, text, url, *, size=12, bold=False, right=False, kind="link_click"):
788
+ """Borderless button styled as a link (system link color, underlined).
789
+ The URL and interaction kind ride on the button's integer tag via
790
+ _link_targets so one openLink: selector serves every link on the card."""
791
+ btn = NSButton.alloc().initWithFrame_(frame)
792
+ btn.setBordered_(False)
793
+ font = NSFont.boldSystemFontOfSize_(size) if bold else NSFont.systemFontOfSize_(size)
794
+ attrs = {
795
+ NSFontAttributeName: font,
796
+ NSForegroundColorAttributeName: NSColor.linkColor(),
797
+ NSUnderlineStyleAttributeName: NSUnderlineStyleSingle,
798
+ }
799
+ btn.setAttributedTitle_(
800
+ NSAttributedString.alloc().initWithString_attributes_(text, attrs)
801
+ )
802
+ btn.setAlignment_(NSTextAlignmentRight if right else NSTextAlignmentLeft)
803
+ tag = len(self._link_targets) + 1
804
+ btn.setTag_(tag)
805
+ self._link_targets[tag] = (str(url), kind)
806
+ btn.setTarget_(self)
807
+ btn.setAction_("openLink:")
808
+ content.addSubview_(btn)
809
+ return btn
810
+
811
+ def openLink_(self, sender):
812
+ try:
813
+ target = self._link_targets.get(sender.tag())
814
+ if target:
815
+ url, kind = target
816
+ self._track(kind)
817
+ NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(url))
818
+ except Exception:
819
+ pass
820
+
821
+ # NSTextView delegate: the thread ↗ link. Track, then open ourselves
822
+ # (returning True suppresses the default handler).
823
+ def textView_clickedOnLink_atIndex_(self, textview, link, charIndex):
824
+ self._track("thread_click")
825
+ try:
826
+ url = link if hasattr(link, "absoluteString") else NSURL.URLWithString_(str(link))
827
+ NSWorkspace.sharedWorkspace().openURL_(url)
828
+ except Exception:
829
+ pass
830
+ return True
831
+
832
+ @objc.python_method
833
+ def _current_text(self):
834
+ try:
835
+ return str(self._textview.string())
836
+ except Exception:
837
+ return self._drafts[self._idx].get("reply_text") or ""
838
+
839
+ @objc.python_method
840
+ def _record(self, approved, reject_category=None, reject_note=None, loved=False):
841
+ d = self._drafts[self._idx]
842
+ orig = (d.get("reply_text") or "").strip()
843
+ link = d.get("link_url") or ""
844
+ drop_link = False
845
+ if approved:
846
+ text = self._current_text()
847
+ # The card folds link_url into the editable field (see _render). On
848
+ # send we must reconcile what the user did with that link:
849
+ # - link still present anywhere -> remove ALL occurrences so the
850
+ # poster mints the single tracked /r/<code> short link (no double
851
+ # link, no bare URL). Generalizes the old endswith() strip, which
852
+ # missed the link whenever the user typed anything after it.
853
+ # - link gone (user deleted it while editing) -> drop_link=True so
854
+ # the poster does NOT re-append it. Without this signal the post
855
+ # pipeline's forced TWITTER_TAIL_LINK_RATE=1.0 silently revived a
856
+ # link the user intentionally removed.
857
+ if link:
858
+ if link in text:
859
+ text = text.replace(link, "")
860
+ else:
861
+ drop_link = True
862
+ # Collapse only horizontal whitespace left by the removal; preserve
863
+ # any newlines the user intended.
864
+ body = re.sub(r"[ \t]{2,}", " ", text).strip()
865
+ else:
866
+ body = orig
867
+ self._decisions.append(
868
+ {
869
+ "n": d["n"],
870
+ "approved": bool(approved),
871
+ "loved": bool(approved and loved),
872
+ "text": body,
873
+ "edited": bool(approved and body != orig),
874
+ "drop_link": bool(approved and drop_link),
875
+ "reject_category": reject_category,
876
+ "reject_note": (reject_note or "").strip() or None,
877
+ "interactions": list(self._interactions),
878
+ "dwell_ms": self._dwell_ms(),
879
+ # Ride-along candidate context (from review_drafts) so the
880
+ # decision can be shipped to /api/v1/review-events without
881
+ # re-reading the plan.
882
+ "candidate_id": d.get("candidate_id"),
883
+ "project": d.get("project"),
884
+ "thread_url": d.get("thread_url"),
885
+ "thread_author": d.get("thread_author"),
886
+ }
887
+ )
888
+ self._last_decision_at = time.time()
889
+ self._log_surface("decision")
890
+
891
+ @objc.python_method
892
+ def _advance(self):
893
+ self._idx += 1
894
+ if self._idx >= len(self._drafts):
895
+ self._finish()
896
+ else:
897
+ self._render()
898
+
899
+ @objc.python_method
900
+ def extend_drafts(self, drafts):
901
+ """Append newly-queued drafts to an OPEN card. Without this, a card built
902
+ when N drafts were pending froze at "of N": every draft that arrived after
903
+ the card opened was stranded behind it (the menu bar bailed while a panel
904
+ was up). Dedups by plan index `n`, never disturbs the card on screen, and
905
+ refreshes the title-bar counter live so the backlog is honest."""
906
+ if self._panel is None:
907
+ return 0
908
+ have = {d.get("n") for d in self._drafts}
909
+ added = [d for d in drafts if d.get("n") not in have]
910
+ if not added:
911
+ return 0
912
+ self._drafts.extend(added)
913
+ # Update only the "X of N" counter; do NOT re-render the body (that would
914
+ # reset the caret / clobber an in-progress edit on the current card).
915
+ try:
916
+ self._panel.setTitle_(
917
+ f"Review draft {self._idx + 1} of {len(self._drafts)}"
918
+ )
919
+ except Exception:
920
+ pass
921
+ self._log_surface(f"extended +{len(added)}")
922
+ return len(added)
923
+
924
+ @objc.python_method
925
+ def _fire_decision(self):
926
+ # Fire the per-card callback the instant a decision is made, so an
927
+ # approved draft starts posting immediately instead of waiting for the
928
+ # whole batch to be reviewed. A throwing callback must never break the
929
+ # card flow (or the panel would wedge on the current card).
930
+ cb = self._on_decision
931
+ if cb is None or not self._decisions:
932
+ return
933
+ try:
934
+ cb(dict(self._decisions[-1]))
935
+ except Exception:
936
+ pass
937
+
938
+ # ObjC selectors (trailing underscore -> "approve:" etc.)
939
+ def approve_(self, sender):
940
+ self._record(True)
941
+ self._fire_decision()
942
+ self._advance()
943
+
944
+ def approveLoved_(self, sender):
945
+ _log("approved with love")
946
+ self._record(True, loved=True)
947
+ self._fire_decision()
948
+ self._advance()
949
+
950
+ def feedbackOpen_(self, sender):
951
+ # Overall feedback is orthogonal to the card decision: the composer
952
+ # opens alongside, the card stays as-is (including in-progress edits).
953
+ self._track("feedback_open")
954
+ present_feedback()
955
+
956
+ def reject_(self, sender):
957
+ # Two-step reject: swap the card body for the reason picker. The
958
+ # decision is recorded when a reason chip (or Skip) is clicked. Any
959
+ # in-progress edit of the reply is preserved for Back.
960
+ self._pending_reply_text = self._current_text()
961
+ self._render_reason()
962
+
963
+ @objc.python_method
964
+ def _render_reason(self):
965
+ content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, W, H))
966
+ content.addSubview_(
967
+ _label(NSMakeRect(M, H - 46, W - 2 * M, 20), "Why reject this draft?", size=13, bold=True)
968
+ )
969
+ y = H - 82
970
+ for i, (_, title) in enumerate(REJECT_REASONS):
971
+ btn = NSButton.alloc().initWithFrame_(NSMakeRect(M, y, W - 2 * M, 28))
972
+ btn.setTitle_(title)
973
+ btn.setBezelStyle_(NSBezelStyleRounded)
974
+ btn.setTag_(i + 1)
975
+ btn.setTarget_(self)
976
+ btn.setAction_("rejectReason:")
977
+ content.addSubview_(btn)
978
+ y -= 32
979
+ note = NSTextField.alloc().initWithFrame_(NSMakeRect(M, 56, W - 2 * M, 48))
980
+ note.setEditable_(True)
981
+ note.setBezeled_(True)
982
+ note.setFont_(NSFont.systemFontOfSize_(12))
983
+ try:
984
+ note.setPlaceholderString_("Optional note (sent with whichever reason you pick)")
985
+ note.cell().setWraps_(True)
986
+ except Exception:
987
+ pass
988
+ content.addSubview_(note)
989
+ self._reason_field = note
990
+
991
+ back = NSButton.alloc().initWithFrame_(NSMakeRect(M, 14, 90, 30))
992
+ back.setTitle_("Back")
993
+ back.setBezelStyle_(NSBezelStyleRounded)
994
+ back.setTarget_(self)
995
+ back.setAction_("rejectBack:")
996
+ content.addSubview_(back)
997
+
998
+ skip = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 150, 14, 150, 30))
999
+ skip.setTitle_("Reject (no reason)")
1000
+ skip.setBezelStyle_(NSBezelStyleRounded)
1001
+ skip.setTarget_(self)
1002
+ skip.setAction_("rejectSkip:")
1003
+ content.addSubview_(skip)
1004
+
1005
+ self._textview = None
1006
+ self._panel.setContentView_(content)
1007
+ self._panel.makeFirstResponder_(note)
1008
+
1009
+ @objc.python_method
1010
+ def _reason_note(self):
1011
+ try:
1012
+ return str(self._reason_field.stringValue()) if self._reason_field is not None else ""
1013
+ except Exception:
1014
+ return ""
1015
+
1016
+ def rejectReason_(self, sender):
1017
+ try:
1018
+ category = REJECT_REASONS[int(sender.tag()) - 1][0]
1019
+ except Exception:
1020
+ category = "other"
1021
+ _log(f"reject reason: {category}")
1022
+ self._record(False, reject_category=category, reject_note=self._reason_note())
1023
+ self._fire_decision()
1024
+ self._advance()
1025
+
1026
+ def rejectSkip_(self, sender):
1027
+ _log("reject reason: skipped")
1028
+ self._record(False, reject_note=self._reason_note())
1029
+ self._fire_decision()
1030
+ self._advance()
1031
+
1032
+ def rejectBack_(self, sender):
1033
+ # Re-render the card and restore any reply edit made before Reject.
1034
+ pending = getattr(self, "_pending_reply_text", None)
1035
+ self._render()
1036
+ if pending is not None and self._textview is not None:
1037
+ try:
1038
+ self._textview.setString_(pending)
1039
+ except Exception:
1040
+ pass
1041
+
1042
+ def windowShouldClose_(self, sender):
1043
+ # Closing the window stops review; remaining cards are left undecided
1044
+ # (not posted). Finish with whatever was decided so far.
1045
+ self._finish()
1046
+ return True
1047
+
1048
+ @objc.python_method
1049
+ def _finish(self):
1050
+ global _active
1051
+ self._close_stats_popover()
1052
+ try:
1053
+ if self._panel is not None:
1054
+ self._panel.setDelegate_(None)
1055
+ self._panel.close()
1056
+ except Exception:
1057
+ pass
1058
+ self._panel = None
1059
+ cb, self._on_complete = self._on_complete, None
1060
+ if cb is not None:
1061
+ try:
1062
+ cb(list(self._decisions))
1063
+ except Exception:
1064
+ pass
1065
+ _active = None
1066
+ _log(f"closed: {len(self._decisions)} decided of {len(self._drafts)}")
1067
+ _write_review_state(last_event="closed")
1068
+
1069
+
1070
+ def present_review(drafts, on_decision=None, on_complete=None):
1071
+ """Show the review cards (main thread only). drafts: list of
1072
+ {n, thread_author, thread_text, reply_text, link_url, thread_url?, stats?}
1073
+ where stats is the discovery-time candidate snapshot
1074
+ {author_followers, likes, retweets, replies, views, tweet_posted_at, ...}
1075
+ (optional). thread_url renders as a trailing ↗ link on the thread text;
1076
+ followers show inline next to the handle, age muted at the right, and the
1077
+ thread engagement counts live only in the eye icon's hover/click popover.
1078
+ on_decision(decision) fires the instant each card is approved/rejected (so an
1079
+ approved draft posts right away); on_complete(decisions) fires when the user
1080
+ finishes the last card or closes the window. Both run on the main thread."""
1081
+ global _active
1082
+ if not drafts:
1083
+ if on_complete is not None:
1084
+ on_complete([])
1085
+ return
1086
+ _active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_(
1087
+ drafts, on_decision, on_complete
1088
+ )
1089
+
1090
+
1091
+ def extend_active(drafts):
1092
+ """Push newly-queued drafts into the open review card, if one is up. Returns
1093
+ the count actually added (0 if no card is open or nothing is new). Main thread
1094
+ only (called from the menu bar's rumps timer)."""
1095
+ if _active is None:
1096
+ return 0
1097
+ try:
1098
+ return _active.extend_drafts(drafts)
1099
+ except Exception:
1100
+ return 0
1101
+
1102
+
1103
+ def active_status():
1104
+ """Live review-surface snapshot for the menu bar's unattended-review
1105
+ watchdog, or None when no card is open. Main thread only."""
1106
+ c = _active
1107
+ if c is None or c._panel is None:
1108
+ return None
1109
+ try:
1110
+ return c.status_dict()
1111
+ except Exception:
1112
+ return None
1113
+
1114
+
1115
+ def heal_active():
1116
+ """Self-heal an unattended card: move it to the top-right of the screen the
1117
+ pointer is on and raise it, WITHOUT stealing keyboard focus (the user is
1118
+ mid-something elsewhere by definition). Main thread only. Returns True if a
1119
+ card was moved."""
1120
+ c = _active
1121
+ if c is None or c._panel is None:
1122
+ return False
1123
+ try:
1124
+ # setFrame_ takes a WINDOW frame (content + title bar), but
1125
+ # _corner_frame is a CONTENT-sized rect. Passing it directly shrank
1126
+ # the window by the title-bar height on every heal, and since content
1127
+ # anchors at the bottom, the top ~19px of the card (the button row)
1128
+ # got clipped under the title bar. Convert to a window frame first
1129
+ # and place THAT in the corner.
1130
+ panel = c._panel
1131
+ scr = _mouse_screen()
1132
+ vf = (
1133
+ scr.visibleFrame()
1134
+ if scr is not None
1135
+ else NSMakeRect(0, 0, 1440, 900)
1136
+ )
1137
+ wf = panel.frameRectForContentRect_(NSMakeRect(0, 0, W, H))
1138
+ panel.setFrame_display_(
1139
+ NSMakeRect(
1140
+ vf.origin.x + vf.size.width - wf.size.width - M,
1141
+ vf.origin.y + vf.size.height - wf.size.height - M,
1142
+ wf.size.width,
1143
+ wf.size.height,
1144
+ ),
1145
+ True,
1146
+ )
1147
+ panel.orderFrontRegardless()
1148
+ c._log_surface("healed")
1149
+ return True
1150
+ except Exception:
1151
+ return False
1152
+
1153
+
1154
+ # ---- overall-feedback composer ----------------------------------------------
1155
+ # One small floating panel with a free-text field, for guidance that is about
1156
+ # the PIPELINE rather than any single draft ("less shilling", "more dev
1157
+ # threads", ...). Reachable from the card's 💬 button and the menu bar's
1158
+ # "Send feedback…" item; both call present_feedback(), which falls back to the
1159
+ # handler the menu bar registered at boot via set_feedback_handler() (that
1160
+ # handler ships a decision='feedback' review event down the same outbox rail
1161
+ # as card decisions, so the digest processes it the same way).
1162
+
1163
+ FB_W = 380
1164
+ FB_H = 200
1165
+
1166
+ # Default submit handler (menu bar's shipper). Module-level so the card's 💬
1167
+ # button can open the composer without threading a callback through
1168
+ # present_review's signature.
1169
+ _feedback_handler = None
1170
+ # Strong ref to the live composer, same pyobjc-GC footgun as _active.
1171
+ _feedback_active = None
1172
+
1173
+
1174
+ def set_feedback_handler(cb):
1175
+ """Register the default on_submit for present_feedback(). The menu bar
1176
+ calls this once at boot with its review-event shipper."""
1177
+ global _feedback_handler
1178
+ _feedback_handler = cb
1179
+
1180
+
1181
+ def _feedback_frame():
1182
+ """Just below the open review card when there is one (so it never covers
1183
+ the card being reviewed), else the top-right corner of the pointer's
1184
+ screen."""
1185
+ scr = _mouse_screen()
1186
+ vf = (
1187
+ scr.visibleFrame()
1188
+ if scr is not None
1189
+ else NSMakeRect(0, 0, 1440, 900)
1190
+ )
1191
+ x = vf.origin.x + vf.size.width - FB_W - M
1192
+ y = vf.origin.y + vf.size.height - FB_H - M
1193
+ if _active is not None and _active._panel is not None:
1194
+ try:
1195
+ fr = _active._panel.frame()
1196
+ x = fr.origin.x
1197
+ y = max(fr.origin.y - FB_H - 10, vf.origin.y + M)
1198
+ except Exception:
1199
+ pass
1200
+ return NSMakeRect(x, y, FB_W, FB_H)
1201
+
1202
+
1203
+ class _FeedbackController(NSObject):
1204
+ def initWithOnSubmit_(self, on_submit):
1205
+ self = objc.super(_FeedbackController, self).init()
1206
+ if self is None:
1207
+ return None
1208
+ self._on_submit = on_submit
1209
+ self._panel = None
1210
+ self._tv = None
1211
+ self._build()
1212
+ return self
1213
+
1214
+ @objc.python_method
1215
+ def _build(self):
1216
+ style = (
1217
+ NSWindowStyleMaskTitled
1218
+ | NSWindowStyleMaskClosable
1219
+ | NSWindowStyleMaskUtilityWindow
1220
+ )
1221
+ panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
1222
+ _feedback_frame(), style, NSBackingStoreBuffered, False
1223
+ )
1224
+ panel.setLevel_(NSFloatingWindowLevel)
1225
+ panel.setFloatingPanel_(True)
1226
+ panel.setBecomesKeyOnlyIfNeeded_(False)
1227
+ panel.setHidesOnDeactivate_(False)
1228
+ panel.setReleasedWhenClosed_(False)
1229
+ panel.setDelegate_(self)
1230
+ panel.setTitle_("Overall feedback")
1231
+
1232
+ content = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, FB_W, FB_H))
1233
+ content.addSubview_(
1234
+ _label(
1235
+ NSMakeRect(M, FB_H - 48, FB_W - 2 * M, 32),
1236
+ "Standing guidance for the drafting loop (thread choice, tone, "
1237
+ "audience), not about any single draft.",
1238
+ size=11,
1239
+ muted=True,
1240
+ )
1241
+ )
1242
+ scroll = NSScrollView.alloc().initWithFrame_(
1243
+ NSMakeRect(M, 54, FB_W - 2 * M, FB_H - 48 - 8 - 54)
1244
+ )
1245
+ scroll.setHasVerticalScroller_(True)
1246
+ scroll.setBorderType_(NS_BEZEL_BORDER)
1247
+ tv = NSTextView.alloc().initWithFrame_(
1248
+ NSMakeRect(0, 0, FB_W - 2 * M, 80)
1249
+ )
1250
+ tv.setFont_(NSFont.systemFontOfSize_(12))
1251
+ tv.setRichText_(False)
1252
+ tv.setEditable_(True)
1253
+ tv.setSelectable_(True)
1254
+ scroll.setDocumentView_(tv)
1255
+ content.addSubview_(scroll)
1256
+ self._tv = tv
1257
+
1258
+ cancel = NSButton.alloc().initWithFrame_(NSMakeRect(M, 14, 90, 30))
1259
+ cancel.setTitle_("Cancel")
1260
+ cancel.setBezelStyle_(NSBezelStyleRounded)
1261
+ cancel.setTarget_(self)
1262
+ cancel.setAction_("feedbackCancel:")
1263
+ content.addSubview_(cancel)
1264
+
1265
+ send = NSButton.alloc().initWithFrame_(NSMakeRect(FB_W - M - 90, 14, 90, 30))
1266
+ send.setTitle_("Send")
1267
+ send.setBezelStyle_(NSBezelStyleRounded)
1268
+ send.setTarget_(self)
1269
+ send.setAction_("feedbackSend:")
1270
+ content.addSubview_(send)
1271
+
1272
+ panel.setContentView_(content)
1273
+ self._panel = panel
1274
+ panel.makeKeyAndOrderFront_(None)
1275
+ panel.orderFrontRegardless()
1276
+ try:
1277
+ NSApp.setActivationPolicy_(NSApplicationActivationPolicyAccessory)
1278
+ NSApp.activateIgnoringOtherApps_(True)
1279
+ except Exception:
1280
+ pass
1281
+ panel.makeFirstResponder_(tv)
1282
+ _log("feedback composer opened")
1283
+
1284
+ @objc.python_method
1285
+ def _close(self):
1286
+ global _feedback_active
1287
+ try:
1288
+ if self._panel is not None:
1289
+ self._panel.setDelegate_(None)
1290
+ self._panel.close()
1291
+ except Exception:
1292
+ pass
1293
+ self._panel = None
1294
+ _feedback_active = None
1295
+
1296
+ def feedbackSend_(self, sender):
1297
+ text = ""
1298
+ try:
1299
+ text = str(self._tv.string()).strip()
1300
+ except Exception:
1301
+ pass
1302
+ cb = self._on_submit
1303
+ self._close()
1304
+ if not text:
1305
+ _log("feedback composer sent empty; dropped")
1306
+ return
1307
+ _log(f"feedback submitted ({len(text)} chars)")
1308
+ if cb is not None:
1309
+ try:
1310
+ cb(text)
1311
+ except Exception:
1312
+ pass
1313
+
1314
+ def feedbackCancel_(self, sender):
1315
+ _log("feedback composer cancelled")
1316
+ self._close()
1317
+
1318
+ def windowShouldClose_(self, sender):
1319
+ self._close()
1320
+ return True
1321
+
1322
+
1323
+ def present_feedback(on_submit=None):
1324
+ """Open (or raise) the overall-feedback composer. Main thread only.
1325
+ on_submit(text) fires only when the user sends non-empty text; defaults to
1326
+ the handler registered via set_feedback_handler()."""
1327
+ global _feedback_active
1328
+ cb = on_submit if on_submit is not None else _feedback_handler
1329
+ if _feedback_active is not None and _feedback_active._panel is not None:
1330
+ try:
1331
+ _feedback_active._panel.makeKeyAndOrderFront_(None)
1332
+ _feedback_active._panel.orderFrontRegardless()
1333
+ return
1334
+ except Exception:
1335
+ pass
1336
+ _feedback_active = _FeedbackController.alloc().initWithOnSubmit_(cb)