@m13v/s4l 1.7.6-rc.2 → 1.7.6-rc.21
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.
- package/mcp/dist/index.js +256 -92
- package/mcp/dist/telemetry.js +2 -1
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +7 -1
- package/mcp/menubar/s4l_card.py +144 -18
- package/mcp/menubar/s4l_card_canvas.py +135 -14
- package/mcp/menubar/s4l_menubar.py +216 -73
- package/mcp/menubar/s4l_state.py +45 -130
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/account_resolver.py +35 -1
- package/scripts/autopilot_stall_watch.py +8 -1
- package/scripts/browser_lifecycle.py +90 -0
- package/scripts/cdp_ready_check.py +81 -50
- package/scripts/draft_prompt_core.py +100 -17
- package/scripts/engagement_styles.py +135 -98
- package/scripts/merge_review_queue.py +192 -129
- package/scripts/platform_strike_events.py +54 -5
- package/scripts/post_reddit.py +10 -0
- package/scripts/preview_reddit_card.py +38 -0
- package/scripts/reap_stale_claude_sessions.py +7 -6
- package/scripts/reddit_ban_check.py +220 -0
- package/scripts/reddit_browser.py +24 -0
- package/scripts/release-mcpb.sh +38 -7
- package/scripts/setup_twitter_auth.py +20 -96
- package/scripts/store_patch.py +111 -0
- package/scripts/strike_alert.py +161 -12
- package/scripts/test_no_silent_fallbacks.py +11 -0
- package/scripts/test_stall_watch_batch_progression.py +99 -0
- package/scripts/top_performers.py +24 -0
- package/scripts/twitter_browser.py +22 -52
- package/skill/archive-old-logs.sh +24 -0
- package/skill/lib/reddit-backend.sh +1 -0
- package/skill/run-twitter-cycle.sh +146 -66
|
@@ -519,9 +519,11 @@ class S4LMenuBar(rumps.App):
|
|
|
519
519
|
# Which review-surface module ("cards" | "canvas") is driving the
|
|
520
520
|
# CURRENTLY open panel/window, None when nothing is open. Set at
|
|
521
521
|
# presentation time from st.read_review_layout(); kept fixed for the
|
|
522
|
-
# life of that surface so
|
|
523
|
-
#
|
|
524
|
-
#
|
|
522
|
+
# life of that surface so no call ever talks to the wrong module for
|
|
523
|
+
# an already-open panel. A "View Mode" (Cards/Canvas) switch while a
|
|
524
|
+
# surface is open dismisses it through this pin, then releases the
|
|
525
|
+
# pin and re-presents through the new module. See _review_mod() and
|
|
526
|
+
# _on_review_layout_preset().
|
|
525
527
|
self._review_mod_name = None
|
|
526
528
|
# Review snooze: epoch until which draft cards must not auto-present
|
|
527
529
|
# (0 = not snoozed). Loaded from disk so a menubar restart mid-snooze
|
|
@@ -577,6 +579,12 @@ class S4LMenuBar(rumps.App):
|
|
|
577
579
|
# a hitch every 5s tick.
|
|
578
580
|
self._resumed = False
|
|
579
581
|
self._loopback_check_due_at = 0.0
|
|
582
|
+
# Periodic approved-backlog drain cadence (2026-07-17): the menubar is
|
|
583
|
+
# the single drainer now that the MCP boot-time drain is gone. 10 min
|
|
584
|
+
# keeps a transiently-failed approval's retry latency reasonable while
|
|
585
|
+
# staying far below the old effective 5-min/multi-instance churn.
|
|
586
|
+
self.RESUME_PERIOD_S = 600.0
|
|
587
|
+
self._resume_due_at = 0.0
|
|
580
588
|
# Reliable self-check of our own Accessibility (TCC) grant — this is the
|
|
581
589
|
# faithful reading (our launchd process identity, not a parent's). Logged
|
|
582
590
|
# so menubar.err.log records whether keystroke posting will work.
|
|
@@ -1140,12 +1148,53 @@ class S4LMenuBar(rumps.App):
|
|
|
1140
1148
|
def _on_review_layout_preset(self, layout, _sender=None):
|
|
1141
1149
|
"""Menu callback shim (mirrors _on_split_preset): rumps passes the
|
|
1142
1150
|
clicked MenuItem last. Persisted (st.write_review_layout), so it
|
|
1143
|
-
survives a restart
|
|
1144
|
-
|
|
1145
|
-
|
|
1151
|
+
survives a restart. If a review surface is on screen when the mode
|
|
1152
|
+
flips, swap it live: close the old surface WITHOUT firing its
|
|
1153
|
+
callbacks (decisions already made were shipped per-card; the
|
|
1154
|
+
undecided remainder must not be treated as a snoozed early-close)
|
|
1155
|
+
and immediately re-present the same pending set through the new
|
|
1156
|
+
module (2026-07-16, replacing the old next-presentation-only
|
|
1157
|
+
behavior)."""
|
|
1146
1158
|
if st.read_review_layout() == layout:
|
|
1147
1159
|
return
|
|
1148
1160
|
st.write_review_layout(layout)
|
|
1161
|
+
if self._panel_open:
|
|
1162
|
+
try:
|
|
1163
|
+
# _review_mod() is still pinned to the module that owns the
|
|
1164
|
+
# open surface (the pin releases below), so this dismisses
|
|
1165
|
+
# the OLD surface, not the freshly-persisted preference.
|
|
1166
|
+
_, mod = self._review_mod()
|
|
1167
|
+
|
|
1168
|
+
mod.dismiss_active()
|
|
1169
|
+
except Exception as e:
|
|
1170
|
+
sys.stderr.write(f"[s4l-menubar] view mode swap dismiss failed: {e}\n")
|
|
1171
|
+
sys.stderr.flush()
|
|
1172
|
+
with self._review_lock:
|
|
1173
|
+
self._panel_open = False
|
|
1174
|
+
self._review_mod_name = None
|
|
1175
|
+
if self._posts_outstanding <= 0:
|
|
1176
|
+
self._review_active = False
|
|
1177
|
+
self._reset_posting_progress_locked()
|
|
1178
|
+
# Changing the View Mode is a deliberate ask to see the pending drafts in
|
|
1179
|
+
# the newly chosen surface, whether or not a panel was open at the time.
|
|
1180
|
+
# When one was open we just dismissed it above; when none was (the user
|
|
1181
|
+
# had closed/snoozed the stack, THEN switched mode) the old behavior left
|
|
1182
|
+
# nothing on screen and the new surface never appeared until a separate
|
|
1183
|
+
# "Review N pending drafts" click -- the 2026-07-17 Nhat report where a
|
|
1184
|
+
# flip to Canvas seemed to do nothing. Present in the new surface either
|
|
1185
|
+
# way, same posture as that menu click: focus=True clears the snooze and
|
|
1186
|
+
# bypasses the reveal-cadence hold. Drop the dedup signature first so the
|
|
1187
|
+
# same pending set presents fresh on the new surface. With no pending
|
|
1188
|
+
# drafts _maybe_start_review no-ops, so setting the mode ahead of time
|
|
1189
|
+
# still doesn't pop anything; if posting is draining it defers and the
|
|
1190
|
+
# backlog presents on the new surface once the drain completes.
|
|
1191
|
+
self._last_review_sig = None
|
|
1192
|
+
try:
|
|
1193
|
+
self._set_snooze(0.0)
|
|
1194
|
+
self._maybe_start_review(focus=True)
|
|
1195
|
+
except Exception as e:
|
|
1196
|
+
sys.stderr.write(f"[s4l-menubar] view mode present failed: {e}\n")
|
|
1197
|
+
sys.stderr.flush()
|
|
1149
1198
|
self._sig = None
|
|
1150
1199
|
try:
|
|
1151
1200
|
self._tick(None)
|
|
@@ -1173,7 +1222,9 @@ class S4LMenuBar(rumps.App):
|
|
|
1173
1222
|
# as drafts are ready). Manual mode.json edits can set any value; the
|
|
1174
1223
|
# checkmark then lands on an exact match or none, same as the lane split.
|
|
1175
1224
|
REVEAL_CADENCE_PRESETS = (
|
|
1176
|
-
|
|
1225
|
+
# "Immediately" alone read as "clicking this pops cards right now";
|
|
1226
|
+
# it only sets the pacing for when the NEXT draft lands (2026-07-16).
|
|
1227
|
+
(0, "Immediately when a new draft is ready"),
|
|
1177
1228
|
(1800, "Every 30 minutes"),
|
|
1178
1229
|
(3600, "Every hour"),
|
|
1179
1230
|
(14400, "Every 4 hours"),
|
|
@@ -1202,7 +1253,7 @@ class S4LMenuBar(rumps.App):
|
|
|
1202
1253
|
if round(secs) == preset:
|
|
1203
1254
|
return label
|
|
1204
1255
|
m = round(secs / 60)
|
|
1205
|
-
return f"Every {m} min" if m else "Immediately"
|
|
1256
|
+
return f"Every {m} min" if m else "Immediately when a new draft is ready"
|
|
1206
1257
|
|
|
1207
1258
|
def _on_cadence_preset(self, secs, _sender=None):
|
|
1208
1259
|
"""Menu callback shim: rumps passes the clicked MenuItem last."""
|
|
@@ -2258,16 +2309,14 @@ class S4LMenuBar(rumps.App):
|
|
|
2258
2309
|
never confirmed posted (the in-memory _post_q died with the old process).
|
|
2259
2310
|
Skip any the plan already shows as posted, so a card that landed on X just
|
|
2260
2311
|
before the kill — but whose status update was lost — isn't posted twice."""
|
|
2261
|
-
# Store lane (
|
|
2262
|
-
#
|
|
2263
|
-
#
|
|
2312
|
+
# Store lane ONLY (2026-07-17): approved-but-unposted rows in the review
|
|
2313
|
+
# store, the single source of truth. The legacy approved-queue.json lane
|
|
2314
|
+
# (a read-only third truth source that the ledger stopped writing long
|
|
2315
|
+
# ago) was removed — its entries could never settle, so it re-enqueued
|
|
2316
|
+
# the same dead approvals on every resume forever. If a genuinely
|
|
2317
|
+
# pre-store approval ever resurfaces, it is visible in the store lane
|
|
2318
|
+
# or it is gone; do not re-add the union.
|
|
2264
2319
|
pending = list(st.store_pending_posts())
|
|
2265
|
-
seen = {(it.get("batch"), it.get("n")) for it in pending}
|
|
2266
|
-
legacy = [
|
|
2267
|
-
it for it in st.approved_queue_pending()
|
|
2268
|
-
if (it.get("batch"), it.get("n")) not in seen
|
|
2269
|
-
]
|
|
2270
|
-
pending.extend(legacy)
|
|
2271
2320
|
if not pending:
|
|
2272
2321
|
return
|
|
2273
2322
|
posted_ns = set()
|
|
@@ -2284,7 +2333,6 @@ class S4LMenuBar(rumps.App):
|
|
|
2284
2333
|
for it in pending:
|
|
2285
2334
|
batch, n = it.get("batch"), it.get("n")
|
|
2286
2335
|
if n in posted_ns:
|
|
2287
|
-
st.approved_queue_set_status(batch, n, "posted") # reconcile lost update
|
|
2288
2336
|
continue
|
|
2289
2337
|
decision = {
|
|
2290
2338
|
"n": n,
|
|
@@ -2419,8 +2467,19 @@ class S4LMenuBar(rumps.App):
|
|
|
2419
2467
|
if now >= self._loopback_check_due_at:
|
|
2420
2468
|
self._loopback_check_due_at = now + 60.0
|
|
2421
2469
|
if st.loopback_reachable():
|
|
2422
|
-
|
|
2470
|
+
# Periodic backlog drain (2026-07-17): the menubar is now the
|
|
2471
|
+
# SINGLE owner of approved-backlog recovery. The MCP server's
|
|
2472
|
+
# boot-time drainApprovedBacklog was removed (each queue-worker
|
|
2473
|
+
# session boots an MCP, so "boot-time" had become a 5-minute
|
|
2474
|
+
# cron across concurrent instances). Here it runs on the one
|
|
2475
|
+
# long-lived process instead: on loopback recovery and every
|
|
2476
|
+
# RESUME_PERIOD_S thereafter, but only while nothing is
|
|
2477
|
+
# in-flight (avoids enqueueing a card twice while an earlier
|
|
2478
|
+
# drain still holds it in _post_q). _resume_approved_queue is
|
|
2479
|
+
# idempotent against the store (skips posted/terminal rows).
|
|
2480
|
+
if (not self._resumed or now >= self._resume_due_at) and self._posts_outstanding == 0:
|
|
2423
2481
|
self._resumed = True
|
|
2482
|
+
self._resume_due_at = now + self.RESUME_PERIOD_S
|
|
2424
2483
|
try:
|
|
2425
2484
|
self._resume_approved_queue()
|
|
2426
2485
|
except Exception as e:
|
|
@@ -2755,6 +2814,9 @@ class S4LMenuBar(rumps.App):
|
|
|
2755
2814
|
# around HH:MM" line in _state_c).
|
|
2756
2815
|
round(st.read_reveal_cadence()),
|
|
2757
2816
|
int(self._reveal_hold_until(pending_count)),
|
|
2817
|
+
# View mode: rebuilds when the review layout changes (checkmark
|
|
2818
|
+
# in the "View Mode" submenu + whether the cadence item shows).
|
|
2819
|
+
st.read_review_layout(),
|
|
2758
2820
|
)
|
|
2759
2821
|
if sig != self._sig:
|
|
2760
2822
|
self._sig = sig
|
|
@@ -2878,6 +2940,14 @@ class S4LMenuBar(rumps.App):
|
|
|
2878
2940
|
flight, and not snoozed (the snooze label and gate take precedence)."""
|
|
2879
2941
|
if pending_count <= 0 or self._review_active:
|
|
2880
2942
|
return 0.0
|
|
2943
|
+
# Canvas never auto-pops (2026-07-17, user direction): the explicit
|
|
2944
|
+
# "Review N pending drafts" click is the ONLY way to open it. Report
|
|
2945
|
+
# the same indefinite hold as the cards-mode "Never" preset so the
|
|
2946
|
+
# menu shows the courtesy line. The reveal cadence itself remains a
|
|
2947
|
+
# cards-mode concept (its menu control is hidden in canvas mode,
|
|
2948
|
+
# 2026-07-16), so a stale cadence value is never consulted here.
|
|
2949
|
+
if st.read_review_layout() != "cards":
|
|
2950
|
+
return -1.0
|
|
2881
2951
|
now = time.time()
|
|
2882
2952
|
if now < self._review_snooze_until:
|
|
2883
2953
|
return 0.0
|
|
@@ -2890,8 +2960,9 @@ class S4LMenuBar(rumps.App):
|
|
|
2890
2960
|
def _review_mod(self):
|
|
2891
2961
|
"""Which review-surface module drives the review-drafts flow: the
|
|
2892
2962
|
module that owns the currently open panel/window when one is open
|
|
2893
|
-
(so
|
|
2894
|
-
|
|
2963
|
+
(so nothing ever talks to the wrong surface for an already-open
|
|
2964
|
+
one; a "View Mode" switch swaps the open surface itself, see
|
|
2965
|
+
_on_review_layout_preset), else the persisted
|
|
2895
2966
|
st.read_review_layout() preference for a FRESH presentation. Both
|
|
2896
2967
|
modules expose the exact same present_review*/extend_active/
|
|
2897
2968
|
prune_active/active_status/heal_active/focus_active/dismiss_active
|
|
@@ -2997,7 +3068,20 @@ class S4LMenuBar(rumps.App):
|
|
|
2997
3068
|
# card keeps growing live) and skipped for focus=True (the menu's
|
|
2998
3069
|
# "Review N pending drafts" is an explicit ask). _last_review_sig is NOT
|
|
2999
3070
|
# stamped on a hold, so the same set still presents fresh when due.
|
|
3071
|
+
# No panel is open past the _review_active branch above, so the
|
|
3072
|
+
# preference IS the layout that is about to present.
|
|
3000
3073
|
if not focus:
|
|
3074
|
+
if st.read_review_layout() != "cards":
|
|
3075
|
+
# Canvas never auto-pops (2026-07-17, user direction): the
|
|
3076
|
+
# explicit "Review N pending drafts" click (focus=True) is the
|
|
3077
|
+
# ONLY way to open the canvas. The extend-open branch above
|
|
3078
|
+
# still grows an already-open canvas live; _last_review_sig is
|
|
3079
|
+
# NOT stamped here, so the whole backlog presents fresh on the
|
|
3080
|
+
# click.
|
|
3081
|
+
return
|
|
3082
|
+
# Cards mode: pace fresh pop-ups by the reveal cadence. The
|
|
3083
|
+
# cadence control is a cards-only concept (hidden while View Mode
|
|
3084
|
+
# is Canvas, 2026-07-16), so it is never consulted for canvas.
|
|
3001
3085
|
cadence = st.read_reveal_cadence()
|
|
3002
3086
|
if cadence < 0:
|
|
3003
3087
|
# "Never" preset: cards never auto-pop, only an explicit
|
|
@@ -3286,8 +3370,7 @@ class S4LMenuBar(rumps.App):
|
|
|
3286
3370
|
with self._review_lock:
|
|
3287
3371
|
self._panel_open = False
|
|
3288
3372
|
# The surface is confirmed gone: release the pin so the NEXT
|
|
3289
|
-
# presentation re-resolves from st.read_review_layout()
|
|
3290
|
-
# a "View" submenu switch made while this one was open).
|
|
3373
|
+
# presentation re-resolves from st.read_review_layout().
|
|
3291
3374
|
self._review_mod_name = None
|
|
3292
3375
|
if self._posts_outstanding <= 0:
|
|
3293
3376
|
self._review_active = False
|
|
@@ -3510,7 +3593,6 @@ class S4LMenuBar(rumps.App):
|
|
|
3510
3593
|
# No "Posting draft N…" banner: the menu-bar spinner already shows
|
|
3511
3594
|
# live posting progress, so a Notification Center toast per approved
|
|
3512
3595
|
# card is pure noise. Only failures (below) raise a notification.
|
|
3513
|
-
st.approved_queue_set_status(batch, n, "posting")
|
|
3514
3596
|
with self._review_lock:
|
|
3515
3597
|
activity_label = self._posting_activity_label_locked()
|
|
3516
3598
|
cl = [n] if decision.get("drop_link") else None
|
|
@@ -3542,32 +3624,25 @@ class S4LMenuBar(rumps.App):
|
|
|
3542
3624
|
# one registered). Unlike a real posting failure, nothing
|
|
3543
3625
|
# was ever attempted here — post_drafts never reached a
|
|
3544
3626
|
# server — so there's no double-post risk in retrying.
|
|
3545
|
-
# Deliberately do NOT call store_mark_post_failed
|
|
3546
|
-
#
|
|
3547
|
-
# store_pending_posts()/approved_queue_pending(), which is
|
|
3627
|
+
# Deliberately do NOT call store_mark_post_failed: it would
|
|
3628
|
+
# exclude this card from store_pending_posts(), which is
|
|
3548
3629
|
# exactly what _resume_approved_queue() reads on the next
|
|
3549
|
-
# unreachable->reachable edge. Leaving
|
|
3550
|
-
# that existing restart-recovery
|
|
3551
|
-
# automatically once a server re-registers,
|
|
3552
|
-
# stranding it for manual dashboard retry.
|
|
3553
|
-
st.approved_queue_set_status(batch, n, "queued", error="loopback_unreachable")
|
|
3630
|
+
# unreachable->reachable edge. Leaving the store row
|
|
3631
|
+
# approved-unposted lets that existing restart-recovery
|
|
3632
|
+
# path retry it automatically once a server re-registers,
|
|
3633
|
+
# instead of stranding it for manual dashboard retry.
|
|
3554
3634
|
self._notify(
|
|
3555
3635
|
"S4L", "Server unreachable — will retry automatically once Claude Desktop reconnects."
|
|
3556
3636
|
)
|
|
3557
3637
|
else:
|
|
3558
3638
|
posted = res.get("posted") if isinstance(res, dict) else None
|
|
3559
3639
|
if posted == 0:
|
|
3560
|
-
st.approved_queue_set_status(batch, n, "failed", error="posted_0")
|
|
3561
3640
|
st.store_mark_post_failed(batch, n, decision.get("candidate_id"), "posted_0")
|
|
3562
3641
|
self._notify("S4L", f"Draft {n} didn't post — see the dashboard for why.")
|
|
3563
|
-
|
|
3564
|
-
|
|
3565
|
-
|
|
3566
|
-
# stamps posted/our_url into the store itself; the legacy
|
|
3567
|
-
# set_status only settles pre-store ledger entries.
|
|
3568
|
-
st.approved_queue_set_status(batch, n, "posted")
|
|
3642
|
+
# Success is silent: the spinner + dashboard already reflect
|
|
3643
|
+
# it. No per-card "Posted draft N." banner. The server
|
|
3644
|
+
# stamps posted/our_url into the store itself.
|
|
3569
3645
|
except Exception as e:
|
|
3570
|
-
st.approved_queue_set_status(batch, n, "failed", error=str(e)[:200])
|
|
3571
3646
|
st.store_mark_post_failed(batch, n, decision.get("candidate_id"), str(e)[:200])
|
|
3572
3647
|
sys.stderr.write(f"[s4l-menubar] post draft {n} failed: {e}\n")
|
|
3573
3648
|
sys.stderr.flush()
|
|
@@ -3634,20 +3709,79 @@ class S4LMenuBar(rumps.App):
|
|
|
3634
3709
|
pass
|
|
3635
3710
|
|
|
3636
3711
|
def _render_title(self, setup_complete, ob, blocker, attention=False, pending_count=0):
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3712
|
+
# Base label: onboarding progress ("S4L 2/5") while setup is still
|
|
3713
|
+
# running, plain "S4L" once complete.
|
|
3714
|
+
if not setup_complete and ob and not ob.get("complete"):
|
|
3640
3715
|
done = sum(1 for m in ob["milestones"] if m.get("status") == "complete")
|
|
3641
3716
|
base = f"S4L {done}/{len(ob['milestones'])}"
|
|
3642
|
-
elif self._update_available:
|
|
3643
|
-
base = "S4L ⬆" # update available — open the menu to update
|
|
3644
3717
|
else:
|
|
3645
3718
|
base = "S4L"
|
|
3719
|
+
# Status markers are INDEPENDENT — each appends its own glyph when active
|
|
3720
|
+
# so a warning and an available update (and anything added later) surface
|
|
3721
|
+
# at the same time (user request 2026-07-17). They used to be mutually
|
|
3722
|
+
# exclusive if/elif branches, where a warning hid a pending update and an
|
|
3723
|
+
# update hid the onboarding count. Order is fixed (warning before update)
|
|
3724
|
+
# so the bar doesn't reshuffle its glyphs as states toggle.
|
|
3725
|
+
if blocker or attention:
|
|
3726
|
+
base += " ⚠" # warning (setup blocked OR autopilot needs attention)
|
|
3727
|
+
if self._update_available:
|
|
3728
|
+
base += " ⬆" # update available — open the menu to update
|
|
3646
3729
|
# Concise inline backlog count, right on the bar (not just the dropdown's
|
|
3647
3730
|
# "Review N pending drafts") — per user request 2026-07-15, most useful
|
|
3648
3731
|
# when the reveal cadence is "Never" and this count is the only signal
|
|
3649
3732
|
# that drafts are waiting.
|
|
3733
|
+
# Setting self.title first lays down the PLAIN title (rumps clears any
|
|
3734
|
+
# prior attributed title via setTitle_), so a backlog that drops back
|
|
3735
|
+
# under the threshold automatically loses its highlight without extra
|
|
3736
|
+
# bookkeeping. _highlight_title_count then re-decorates just the number
|
|
3737
|
+
# when the backlog is large enough to warrant a colored pill.
|
|
3650
3738
|
self.title = f"{base} · {pending_count}" if pending_count > 0 else base
|
|
3739
|
+
if pending_count > 5:
|
|
3740
|
+
self._highlight_title_count(base, pending_count)
|
|
3741
|
+
|
|
3742
|
+
def _highlight_title_count(self, base, pending_count):
|
|
3743
|
+
"""Draw a colored background pill behind the pending-draft NUMBER in the
|
|
3744
|
+
menu-bar title once the backlog gets large: yellow for >5 (6-10), red
|
|
3745
|
+
for >10 (11+). Per user request 2026-07-17 — a plain count is easy to
|
|
3746
|
+
miss, a colored one pulls the eye when drafts are piling up. Applied as
|
|
3747
|
+
an NSAttributedString on the status item's button; on any failure we
|
|
3748
|
+
leave the plain title (already set by the caller) untouched."""
|
|
3749
|
+
try:
|
|
3750
|
+
from AppKit import (
|
|
3751
|
+
NSColor,
|
|
3752
|
+
NSMutableAttributedString,
|
|
3753
|
+
NSForegroundColorAttributeName,
|
|
3754
|
+
NSBackgroundColorAttributeName,
|
|
3755
|
+
)
|
|
3756
|
+
from Foundation import NSMakeRange
|
|
3757
|
+
|
|
3758
|
+
item = getattr(self._nsapp, "nsstatusitem", None)
|
|
3759
|
+
if item is None:
|
|
3760
|
+
return
|
|
3761
|
+
button = item.button() if hasattr(item, "button") else None
|
|
3762
|
+
if button is None:
|
|
3763
|
+
return
|
|
3764
|
+
|
|
3765
|
+
if pending_count > 10:
|
|
3766
|
+
bg = NSColor.systemRedColor()
|
|
3767
|
+
fg = NSColor.whiteColor()
|
|
3768
|
+
else:
|
|
3769
|
+
bg = NSColor.systemYellowColor()
|
|
3770
|
+
fg = NSColor.blackColor()
|
|
3771
|
+
|
|
3772
|
+
num = str(pending_count)
|
|
3773
|
+
# Pad the number with hair spaces so the colored pill has a little
|
|
3774
|
+
# breathing room around the digits instead of hugging them tight.
|
|
3775
|
+
pill = f" {num} "
|
|
3776
|
+
full = f"{base} · {pill}"
|
|
3777
|
+
attr = NSMutableAttributedString.alloc().initWithString_(full)
|
|
3778
|
+
start = full.rfind(pill)
|
|
3779
|
+
rng = NSMakeRange(start, len(pill))
|
|
3780
|
+
attr.addAttribute_value_range_(NSBackgroundColorAttributeName, bg, rng)
|
|
3781
|
+
attr.addAttribute_value_range_(NSForegroundColorAttributeName, fg, rng)
|
|
3782
|
+
button.setAttributedTitle_(attr)
|
|
3783
|
+
except Exception:
|
|
3784
|
+
pass
|
|
3651
3785
|
|
|
3652
3786
|
# ---- menu construction ------------------------------------------------
|
|
3653
3787
|
def _build_menu(self, runtime_ready, setup_complete, ob, blocker, snap, attention=False, schedule_state="ok", pending_count=0):
|
|
@@ -3811,21 +3945,37 @@ class S4LMenuBar(rumps.App):
|
|
|
3811
3945
|
split_menu.add(it)
|
|
3812
3946
|
items.append(split_menu)
|
|
3813
3947
|
|
|
3948
|
+
# View mode: which review surface presents drafts — the one-at-a-time
|
|
3949
|
+
# corner card or the large centered multi-select canvas. ALWAYS
|
|
3950
|
+
# visible (2026-07-16, moved out of _state_c where it only appeared
|
|
3951
|
+
# with pending drafts) so the preference can be set ahead of time;
|
|
3952
|
+
# flipping it while a surface is open swaps that surface live (see
|
|
3953
|
+
# _on_review_layout_preset).
|
|
3954
|
+
layout_now = st.read_review_layout()
|
|
3955
|
+
view_menu = rumps.MenuItem("View Mode")
|
|
3956
|
+
for key, label in (("cards", "Cards"), ("canvas", "Canvas")):
|
|
3957
|
+
it = rumps.MenuItem(label, callback=functools.partial(self._on_review_layout_preset, key))
|
|
3958
|
+
it.state = 1 if layout_now == key else 0
|
|
3959
|
+
view_menu.add(it)
|
|
3960
|
+
items.append(view_menu)
|
|
3961
|
+
|
|
3814
3962
|
# Reveal cadence: how often fresh draft cards may pop (the drafting
|
|
3815
|
-
# pipeline is unchanged; this only paces the pop-up).
|
|
3816
|
-
#
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
it = rumps.MenuItem(
|
|
3823
|
-
label,
|
|
3824
|
-
callback=functools.partial(self._on_cadence_preset, preset),
|
|
3963
|
+
# pipeline is unchanged; this only paces the pop-up). Cards mode only
|
|
3964
|
+
# (2026-07-16): the pacing control is about the corner card
|
|
3965
|
+
# interrupting, so it is hidden while View Mode is Canvas.
|
|
3966
|
+
if layout_now == "cards":
|
|
3967
|
+
cadence = st.read_reveal_cadence()
|
|
3968
|
+
cadence_menu = rumps.MenuItem(
|
|
3969
|
+
f"Show draft cards: {self._cadence_label(cadence).lower()}"
|
|
3825
3970
|
)
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3971
|
+
for preset, label in self.REVEAL_CADENCE_PRESETS:
|
|
3972
|
+
it = rumps.MenuItem(
|
|
3973
|
+
label,
|
|
3974
|
+
callback=functools.partial(self._on_cadence_preset, preset),
|
|
3975
|
+
)
|
|
3976
|
+
it.state = 1 if round(cadence) == preset else 0
|
|
3977
|
+
cadence_menu.add(it)
|
|
3978
|
+
items.append(cadence_menu)
|
|
3829
3979
|
|
|
3830
3980
|
# Posting volume: server-side per-install throttle (virality bar).
|
|
3831
3981
|
# read_posting_mode() is cached and non-blocking; unknown/unset shows
|
|
@@ -3973,20 +4123,8 @@ class S4LMenuBar(rumps.App):
|
|
|
3973
4123
|
callback=self._review_now,
|
|
3974
4124
|
)
|
|
3975
4125
|
]
|
|
3976
|
-
#
|
|
3977
|
-
#
|
|
3978
|
-
# (2026-07-15). A submenu of checkmarked options (same pattern as
|
|
3979
|
-
# "Lane split" above), not a standalone on/off checkbox, and grouped
|
|
3980
|
-
# with the review action itself rather than the always-visible
|
|
3981
|
-
# engagement-lane toggles, since it only matters while there's
|
|
3982
|
-
# something to review.
|
|
3983
|
-
view_menu = rumps.MenuItem("View")
|
|
3984
|
-
layout_now = st.read_review_layout()
|
|
3985
|
-
for key, label in (("cards", "Cards"), ("canvas", "Canvas")):
|
|
3986
|
-
it = rumps.MenuItem(label, callback=functools.partial(self._on_review_layout_preset, key))
|
|
3987
|
-
it.state = 1 if layout_now == key else 0
|
|
3988
|
-
view_menu.add(it)
|
|
3989
|
-
items.append(view_menu)
|
|
4126
|
+
# The review-surface picker ("View Mode") lives in _build_menu now
|
|
4127
|
+
# (2026-07-16): always visible, not just while something is pending.
|
|
3990
4128
|
if time.time() < self._review_snooze_until:
|
|
3991
4129
|
items.append(
|
|
3992
4130
|
self._label(
|
|
@@ -4003,7 +4141,12 @@ class S4LMenuBar(rumps.App):
|
|
|
4003
4141
|
# now regardless.
|
|
4004
4142
|
hold_until = self._reveal_hold_until(pending_count)
|
|
4005
4143
|
if hold_until < 0:
|
|
4006
|
-
|
|
4144
|
+
if st.read_review_layout() == "cards":
|
|
4145
|
+
items.append(self._label("Cards hidden — see count in the menu bar"))
|
|
4146
|
+
else:
|
|
4147
|
+
# Canvas mode: it never auto-opens; the review item above
|
|
4148
|
+
# is the one way in.
|
|
4149
|
+
items.append(self._label("Canvas opens from the review item above"))
|
|
4007
4150
|
elif hold_until:
|
|
4008
4151
|
items.append(
|
|
4009
4152
|
self._label(
|