@m13v/s4l 1.7.2-rc.9 → 1.7.3
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 +61 -9
- package/mcp/dist/screencast.js +27 -0
- package/mcp/dist/setup.js +8 -2
- package/mcp/dist/version.json +2 -2
- package/mcp/manifest.json +1 -1
- package/mcp/menubar/s4l_browser_foreground.py +188 -0
- package/mcp/menubar/s4l_card.py +312 -29
- package/mcp/menubar/s4l_log_relay.py +32 -0
- package/mcp/menubar/s4l_menubar.py +278 -62
- package/mcp/menubar/s4l_state.py +37 -4
- package/mcp/package.json +1 -1
- package/package.json +1 -1
- package/scripts/_process_li_notifs.py +91 -0
- package/scripts/feedback_digest.py +1 -1
- package/scripts/merge_review_queue.py +87 -21
- package/scripts/pick_project.py +12 -3
- package/scripts/release-mcpb.sh +15 -1
- package/scripts/s4l_mode.py +9 -8
- package/scripts/schedule_state.py +53 -0
- package/scripts/twitter_post_plan.py +67 -0
- package/skill/run-draft-and-publish.sh +4 -3
package/mcp/menubar/s4l_card.py
CHANGED
|
@@ -8,6 +8,17 @@ fires once the last card is decided or the window is closed. The whole AppKit
|
|
|
8
8
|
surface is isolated behind that one function so the menu bar wiring doesn't
|
|
9
9
|
depend on the windowing details.
|
|
10
10
|
|
|
11
|
+
The panel is NONACTIVATING and auto-presented cards never take keyboard focus
|
|
12
|
+
(2026-07-09 customer complaint: cards stole the caret mid-typing); the reply
|
|
13
|
+
field becomes editable on first click, and only a user-initiated open
|
|
14
|
+
(present_review(focus=True), the menu bar's "Review N pending drafts")
|
|
15
|
+
activates the app and seats the caret immediately. A title-bar "Snooze 1h"
|
|
16
|
+
button sits top-left where the traffic lights would be (they're hidden: the
|
|
17
|
+
panel can't minimize or zoom, and the cross duplicated Snooze); it just
|
|
18
|
+
closes the panel, and the menu bar interprets any close with undecided
|
|
19
|
+
drafts (including Cmd-W) as a snooze (drafts stay pending, re-present after
|
|
20
|
+
REVIEW_SNOOZE_SECONDS).
|
|
21
|
+
|
|
11
22
|
Decision shape: {"n": int, "approved": bool, "loved": bool, "text": str,
|
|
12
23
|
"edited": bool, "drop_link": bool, "reject_category": str|None,
|
|
13
24
|
"reject_note": str|None, "interactions": [{"type": str, "ts": str}],
|
|
@@ -49,6 +60,7 @@ run loop, so that holds).
|
|
|
49
60
|
|
|
50
61
|
import datetime
|
|
51
62
|
import json
|
|
63
|
+
import os
|
|
52
64
|
import re
|
|
53
65
|
import time
|
|
54
66
|
|
|
@@ -116,6 +128,42 @@ try:
|
|
|
116
128
|
from AppKit import NSVisualEffectView
|
|
117
129
|
except Exception:
|
|
118
130
|
NSVisualEffectView = None
|
|
131
|
+
# Nonactivating panel: an auto-presented card must never yank keyboard focus
|
|
132
|
+
# from whatever the user is typing into (customer complaint 2026-07-09); the
|
|
133
|
+
# panel becomes key only when the user clicks into one of its text fields.
|
|
134
|
+
# Bit value 1 << 7 per AppKit; 0 degrades to the old always-activating panel.
|
|
135
|
+
try:
|
|
136
|
+
from AppKit import NSWindowStyleMaskNonactivatingPanel
|
|
137
|
+
except Exception:
|
|
138
|
+
NSWindowStyleMaskNonactivatingPanel = 0
|
|
139
|
+
# Title-bar "Snooze 1h" button (top-left, where the traffic lights would be;
|
|
140
|
+
# those are hidden because minimize/zoom are disabled no-ops on this panel and
|
|
141
|
+
# the close cross just duplicated Snooze). Missing on very old AppKit; the
|
|
142
|
+
# card then degrades to the stock cross (closing still snoozes, the labeled
|
|
143
|
+
# button is just absent) because the traffic lights are only hidden when the
|
|
144
|
+
# accessory actually mounted.
|
|
145
|
+
try:
|
|
146
|
+
from AppKit import (
|
|
147
|
+
NSTitlebarAccessoryViewController,
|
|
148
|
+
NSLayoutAttributeLeft,
|
|
149
|
+
NSLayoutAttributeRight,
|
|
150
|
+
)
|
|
151
|
+
except Exception:
|
|
152
|
+
NSTitlebarAccessoryViewController = None
|
|
153
|
+
NSLayoutAttributeLeft = None
|
|
154
|
+
NSLayoutAttributeRight = None
|
|
155
|
+
try:
|
|
156
|
+
from AppKit import (
|
|
157
|
+
NSWindowCloseButton,
|
|
158
|
+
NSWindowMiniaturizeButton,
|
|
159
|
+
NSWindowZoomButton,
|
|
160
|
+
)
|
|
161
|
+
except Exception:
|
|
162
|
+
NSWindowCloseButton = NSWindowMiniaturizeButton = NSWindowZoomButton = None
|
|
163
|
+
try:
|
|
164
|
+
from AppKit import NSBezelStyleInline
|
|
165
|
+
except Exception:
|
|
166
|
+
NSBezelStyleInline = None
|
|
119
167
|
try:
|
|
120
168
|
# Registers the CGColor bridge type; without it every NSColor.CGColor()
|
|
121
169
|
# call in _round_rect logs an ObjCPointerWarning to menubar.err.log.
|
|
@@ -149,14 +197,28 @@ REJECT_REASONS = (
|
|
|
149
197
|
# Client-side cap on tracked interactions per card (server clips at 50 too).
|
|
150
198
|
MAX_INTERACTIONS = 50
|
|
151
199
|
|
|
152
|
-
|
|
153
|
-
|
|
200
|
+
|
|
201
|
+
def _snooze_secs():
|
|
202
|
+
"""Snooze duration, for the title-bar button LABEL only. The menu bar owns
|
|
203
|
+
the actual timer (its REVIEW_SNOOZE_SECONDS reads the same env var)."""
|
|
204
|
+
try:
|
|
205
|
+
return max(60, int(float(os.environ.get("S4L_REVIEW_SNOOZE_S", "3600"))))
|
|
206
|
+
except Exception:
|
|
207
|
+
return 3600
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _snooze_title():
|
|
211
|
+
s = _snooze_secs()
|
|
212
|
+
return f"Snooze {s // 3600}h" if s % 3600 == 0 else f"Snooze {s // 60}m"
|
|
213
|
+
|
|
214
|
+
# Inline approve row: glyph + tooltip per approval level (button tag =
|
|
215
|
+
# level). Level 1 = plain approve; 2 = loved=True on the decision, with the
|
|
154
216
|
# exact level shipped as an approve_level_N interaction for the feedback
|
|
155
|
-
# digest.
|
|
217
|
+
# digest. Monochrome text-presentation glyphs (U+FE0E) so the buttons stay
|
|
218
|
+
# black-and-white in both appearances instead of rendering as color emoji.
|
|
156
219
|
APPROVE_EMOJIS = (
|
|
157
|
-
("
|
|
158
|
-
("
|
|
159
|
-
("❤️🔥", "Approve, best of the best"),
|
|
220
|
+
("✓︎", "Approve"),
|
|
221
|
+
("♥︎", "Approve, best of the best"),
|
|
160
222
|
)
|
|
161
223
|
|
|
162
224
|
# Review-surface state mirrored to the state dir for out-of-process observers
|
|
@@ -626,13 +688,21 @@ def _editable_scroll(frame, text=""):
|
|
|
626
688
|
|
|
627
689
|
|
|
628
690
|
class _ReviewController(NSObject):
|
|
629
|
-
def
|
|
691
|
+
def initWithDrafts_onDecision_onComplete_focus_(
|
|
692
|
+
self, drafts, on_decision, on_complete, focus
|
|
693
|
+
):
|
|
630
694
|
self = objc.super(_ReviewController, self).init()
|
|
631
695
|
if self is None:
|
|
632
696
|
return None
|
|
633
697
|
self._drafts = list(drafts)
|
|
634
698
|
self._on_decision = on_decision
|
|
635
699
|
self._on_complete = on_complete
|
|
700
|
+
# focus=True only for a USER-initiated open (the menu bar's "Review N
|
|
701
|
+
# pending drafts"): activate the app and seat the caret in the reply
|
|
702
|
+
# field. Auto-presented cards (the timer tick) must appear without
|
|
703
|
+
# taking keyboard focus from whatever the user is doing.
|
|
704
|
+
self._focus = bool(focus)
|
|
705
|
+
self._close_reason = None # "snooze" when the title-bar button closed us
|
|
636
706
|
self._idx = 0
|
|
637
707
|
self._decisions = []
|
|
638
708
|
self._panel = None
|
|
@@ -776,26 +846,35 @@ class _ReviewController(NSObject):
|
|
|
776
846
|
NSWindowStyleMaskTitled
|
|
777
847
|
| NSWindowStyleMaskClosable
|
|
778
848
|
| NSWindowStyleMaskUtilityWindow
|
|
849
|
+
| NSWindowStyleMaskNonactivatingPanel
|
|
779
850
|
)
|
|
780
851
|
panel = _ReviewPanel.alloc().initWithContentRect_styleMask_backing_defer_(
|
|
781
852
|
frame, style, NSBackingStoreBuffered, False
|
|
782
853
|
)
|
|
783
854
|
panel.setLevel_(NSFloatingWindowLevel)
|
|
784
855
|
panel.setFloatingPanel_(True)
|
|
785
|
-
|
|
856
|
+
# Key only when a text field is clicked: buttons (Approve/Reject) work
|
|
857
|
+
# without ever taking keyboard focus, and clicking into the reply field
|
|
858
|
+
# makes this nonactivating panel key without activating the app, so
|
|
859
|
+
# the user's frontmost app keeps its state. The old False + activate-
|
|
860
|
+
# on-present combination stole the keyboard mid-typing (2026-07-09
|
|
861
|
+
# customer complaint).
|
|
862
|
+
panel.setBecomesKeyOnlyIfNeeded_(True)
|
|
786
863
|
panel.setHidesOnDeactivate_(False)
|
|
787
864
|
panel.setReleasedWhenClosed_(False)
|
|
788
865
|
panel.setDelegate_(self)
|
|
866
|
+
self._add_snooze_accessory(panel)
|
|
789
867
|
self._panel = panel
|
|
790
868
|
self._render()
|
|
791
|
-
panel.makeKeyAndOrderFront_(None)
|
|
792
869
|
panel.orderFrontRegardless()
|
|
793
870
|
self._log_surface("presented")
|
|
794
|
-
self.
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
871
|
+
if self._focus:
|
|
872
|
+
# User asked for the cards (menu item): behave like a normal open,
|
|
873
|
+
# activate and put the caret in the reply field. Deferred second
|
|
874
|
+
# pass because activation/key promotion lands on the run loop.
|
|
875
|
+
panel.makeKeyAndOrderFront_(None)
|
|
876
|
+
self.focusReply_(None)
|
|
877
|
+
self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
|
|
799
878
|
|
|
800
879
|
def focusReply_(self, sender):
|
|
801
880
|
panel = self._panel
|
|
@@ -826,6 +905,120 @@ class _ReviewController(NSObject):
|
|
|
826
905
|
except Exception:
|
|
827
906
|
pass
|
|
828
907
|
|
|
908
|
+
@objc.python_method
|
|
909
|
+
def _add_snooze_accessory(self, panel):
|
|
910
|
+
"""Title-bar controls, replacing the traffic lights: "Snooze 1h" at
|
|
911
|
+
the top-left (minimize/zoom were disabled no-op dots on this panel,
|
|
912
|
+
and the close cross meant snooze anyway, so one labeled button says
|
|
913
|
+
what the only dismissal actually does) and "Discard all…" at the
|
|
914
|
+
top-right (moved here from the menu bar dropdown 2026-07-10; the
|
|
915
|
+
ellipsis is honest, the handler opens a confirmation alert first).
|
|
916
|
+
Snooze just closes the panel; the menu bar treats any close with
|
|
917
|
+
undecided drafts as a snooze. The traffic lights are hidden ONLY once
|
|
918
|
+
the snooze accessory mounts, so a failure on old AppKit leaves the
|
|
919
|
+
stock cross as the fallback dismissal. Cmd-W keeps working either way
|
|
920
|
+
(the Closable mask stays on)."""
|
|
921
|
+
if NSTitlebarAccessoryViewController is None or NSLayoutAttributeLeft is None:
|
|
922
|
+
return
|
|
923
|
+
try:
|
|
924
|
+
self._add_titlebar_button(
|
|
925
|
+
panel,
|
|
926
|
+
title=_snooze_title(),
|
|
927
|
+
action="snoozeClicked:",
|
|
928
|
+
tooltip=(
|
|
929
|
+
"Put these drafts away for now. They stay pending and the "
|
|
930
|
+
"card comes back later (or sooner from the S4L menu)."
|
|
931
|
+
),
|
|
932
|
+
layout=NSLayoutAttributeLeft,
|
|
933
|
+
)
|
|
934
|
+
self._hide_traffic_lights(panel)
|
|
935
|
+
except Exception as e:
|
|
936
|
+
_log(f"snooze accessory unavailable: {e}")
|
|
937
|
+
if _discard_all_handler is not None and NSLayoutAttributeRight is not None:
|
|
938
|
+
try:
|
|
939
|
+
self._add_titlebar_button(
|
|
940
|
+
panel,
|
|
941
|
+
title="Discard all…",
|
|
942
|
+
action="discardAllClicked:",
|
|
943
|
+
tooltip=(
|
|
944
|
+
"Throw away every pending draft (asks first). Nothing "
|
|
945
|
+
"posts, and unlike a per-card reject this sends no "
|
|
946
|
+
"feedback signal to the AI."
|
|
947
|
+
),
|
|
948
|
+
layout=NSLayoutAttributeRight,
|
|
949
|
+
)
|
|
950
|
+
except Exception as e:
|
|
951
|
+
_log(f"discard-all accessory unavailable: {e}")
|
|
952
|
+
|
|
953
|
+
@objc.python_method
|
|
954
|
+
def _add_titlebar_button(self, panel, title, action, tooltip, layout):
|
|
955
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(0, 0, 80, 17))
|
|
956
|
+
btn.setTitle_(title)
|
|
957
|
+
if NSBezelStyleInline is not None:
|
|
958
|
+
btn.setBezelStyle_(NSBezelStyleInline)
|
|
959
|
+
else:
|
|
960
|
+
btn.setBezelStyle_(NSBezelStyleRounded)
|
|
961
|
+
btn.setFont_(NSFont.systemFontOfSize_(10.0))
|
|
962
|
+
btn.setToolTip_(tooltip)
|
|
963
|
+
btn.setTarget_(self)
|
|
964
|
+
btn.setAction_(action)
|
|
965
|
+
btn.sizeToFit()
|
|
966
|
+
bf = btn.frame()
|
|
967
|
+
holder = NSView.alloc().initWithFrame_(
|
|
968
|
+
NSMakeRect(0, 0, bf.size.width + 8, max(19, bf.size.height + 2))
|
|
969
|
+
)
|
|
970
|
+
btn.setFrameOrigin_(
|
|
971
|
+
(4, max(0, (holder.frame().size.height - bf.size.height) / 2.0))
|
|
972
|
+
)
|
|
973
|
+
holder.addSubview_(btn)
|
|
974
|
+
acc = NSTitlebarAccessoryViewController.alloc().init()
|
|
975
|
+
acc.setView_(holder)
|
|
976
|
+
acc.setLayoutAttribute_(layout)
|
|
977
|
+
panel.addTitlebarAccessoryViewController_(acc)
|
|
978
|
+
|
|
979
|
+
@objc.python_method
|
|
980
|
+
def _hide_traffic_lights(self, panel):
|
|
981
|
+
"""Hide close/minimize/zoom once the Snooze accessory is up: minimize
|
|
982
|
+
and zoom are disabled dots (no Miniaturizable/Resizable in the mask)
|
|
983
|
+
and the cross duplicated Snooze. Hiding, not removing: performClose_
|
|
984
|
+
(Cmd-W and snoozeClicked_) still routes through the hidden close
|
|
985
|
+
button's machinery, and windowShouldClose_ still fires."""
|
|
986
|
+
if NSWindowCloseButton is None:
|
|
987
|
+
return
|
|
988
|
+
for which in (
|
|
989
|
+
NSWindowCloseButton,
|
|
990
|
+
NSWindowMiniaturizeButton,
|
|
991
|
+
NSWindowZoomButton,
|
|
992
|
+
):
|
|
993
|
+
try:
|
|
994
|
+
b = panel.standardWindowButton_(which)
|
|
995
|
+
if b is not None:
|
|
996
|
+
b.setHidden_(True)
|
|
997
|
+
except Exception:
|
|
998
|
+
pass
|
|
999
|
+
|
|
1000
|
+
def snoozeClicked_(self, sender):
|
|
1001
|
+
self._track("snooze")
|
|
1002
|
+
self._close_reason = "snooze"
|
|
1003
|
+
try:
|
|
1004
|
+
self._panel.performClose_(None)
|
|
1005
|
+
except Exception:
|
|
1006
|
+
self._finish()
|
|
1007
|
+
|
|
1008
|
+
def discardAllClicked_(self, sender):
|
|
1009
|
+
"""Hand off to the menu bar's bulk-discard handler (registered via
|
|
1010
|
+
set_discard_all_handler); it confirms with the user, flips the store,
|
|
1011
|
+
and dismisses this panel via dismiss_active(), so nothing more happens
|
|
1012
|
+
here. On cancel the card simply stays up."""
|
|
1013
|
+
self._track("discard_all")
|
|
1014
|
+
cb = _discard_all_handler
|
|
1015
|
+
if cb is None:
|
|
1016
|
+
return
|
|
1017
|
+
try:
|
|
1018
|
+
cb()
|
|
1019
|
+
except Exception as e:
|
|
1020
|
+
_log(f"discard-all handler failed: {e}")
|
|
1021
|
+
|
|
829
1022
|
@objc.python_method
|
|
830
1023
|
def _eye_button(self, frame, kind):
|
|
831
1024
|
"""Borderless SF-Symbol eye whose hover/click opens a popover. A plain
|
|
@@ -884,12 +1077,13 @@ class _ReviewController(NSObject):
|
|
|
884
1077
|
# Bezeled, not borderless: bare emoji read as decoration and
|
|
885
1078
|
# users doubted the click registered (2026-07-03/04 feedback,
|
|
886
1079
|
# twice now — the outline is what says "button").
|
|
887
|
-
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42,
|
|
1080
|
+
btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42, 38, 30))
|
|
888
1081
|
btn.setTitle_(emoji)
|
|
889
1082
|
btn.setBezelStyle_(NSBezelStyleRounded)
|
|
890
|
-
#
|
|
891
|
-
#
|
|
892
|
-
|
|
1083
|
+
# 16pt fits the rounded bezel's ~22px content lane for these
|
|
1084
|
+
# text-presentation glyphs (the old color emoji clipped at 16pt,
|
|
1085
|
+
# which is why this used to be 13pt).
|
|
1086
|
+
btn.setFont_(NSFont.systemFontOfSize_(16))
|
|
893
1087
|
btn.setTag_(i + 1) # tag = approval level
|
|
894
1088
|
btn.setTarget_(self)
|
|
895
1089
|
btn.setAction_("approve:")
|
|
@@ -898,7 +1092,7 @@ class _ReviewController(NSObject):
|
|
|
898
1092
|
except Exception:
|
|
899
1093
|
pass
|
|
900
1094
|
content.addSubview_(btn)
|
|
901
|
-
x +=
|
|
1095
|
+
x += 42
|
|
902
1096
|
|
|
903
1097
|
reject = NSButton.alloc().initWithFrame_(NSMakeRect(W - M - 66, H - 42, 66, 30))
|
|
904
1098
|
reject.setTitle_("Reject")
|
|
@@ -1239,9 +1433,15 @@ class _ReviewController(NSObject):
|
|
|
1239
1433
|
)
|
|
1240
1434
|
# setContentView_ rebuilds the view tree, so the caret would otherwise
|
|
1241
1435
|
# default to the Approve button. Re-seat it in the reply field for every
|
|
1242
|
-
# card
|
|
1243
|
-
|
|
1244
|
-
|
|
1436
|
+
# card the user is ACTIVELY reviewing (they opened the stack from the
|
|
1437
|
+
# menu, or already decided/touched something), so each one is
|
|
1438
|
+
# immediately editable. NOT on the initial auto-presented render:
|
|
1439
|
+
# focusReply_ activates the app, and yanking the caret out of whatever
|
|
1440
|
+
# the user was typing was the 2026-07-09 "too distracting" complaint.
|
|
1441
|
+
# An untouched card's field becomes editable on first click instead.
|
|
1442
|
+
if self._focus or self._decisions or self._last_interaction_at is not None:
|
|
1443
|
+
self._panel.makeFirstResponder_(tv)
|
|
1444
|
+
self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
|
|
1245
1445
|
|
|
1246
1446
|
@objc.python_method
|
|
1247
1447
|
def _close_stats_popover(self):
|
|
@@ -1600,6 +1800,37 @@ class _ReviewController(NSObject):
|
|
|
1600
1800
|
self._log_surface(f"extended +{len(added)}")
|
|
1601
1801
|
return len(added)
|
|
1602
1802
|
|
|
1803
|
+
@objc.python_method
|
|
1804
|
+
def prune_drafts(self, ns):
|
|
1805
|
+
"""Drop not-yet-reached drafts (plan index `n` in `ns`) from the stack,
|
|
1806
|
+
e.g. a card the backend already retired (expired freshness gate, etc.)
|
|
1807
|
+
while it was still waiting to be shown. Only ever removes entries AFTER
|
|
1808
|
+
the current index, so the card on screen right now and everything
|
|
1809
|
+
already decided are untouched -- nothing visible disappears out from
|
|
1810
|
+
under the user. Refreshes the title-bar counter live. Returns the
|
|
1811
|
+
count actually removed."""
|
|
1812
|
+
if self._panel is None or not ns:
|
|
1813
|
+
return 0
|
|
1814
|
+
ns = set(ns)
|
|
1815
|
+
kept = []
|
|
1816
|
+
removed = 0
|
|
1817
|
+
for i, d in enumerate(self._drafts):
|
|
1818
|
+
if i > self._idx and d.get("n") in ns:
|
|
1819
|
+
removed += 1
|
|
1820
|
+
continue
|
|
1821
|
+
kept.append(d)
|
|
1822
|
+
if not removed:
|
|
1823
|
+
return 0
|
|
1824
|
+
self._drafts = kept
|
|
1825
|
+
try:
|
|
1826
|
+
self._panel.setTitle_(
|
|
1827
|
+
f"s4l · Review draft {self._idx + 1} of {len(self._drafts)}"
|
|
1828
|
+
)
|
|
1829
|
+
except Exception:
|
|
1830
|
+
pass
|
|
1831
|
+
self._log_surface(f"pruned {removed} expired")
|
|
1832
|
+
return removed
|
|
1833
|
+
|
|
1603
1834
|
@objc.python_method
|
|
1604
1835
|
def _fire_decision(self):
|
|
1605
1836
|
# Fire the per-card callback the instant a decision is made, so an
|
|
@@ -1761,7 +1992,10 @@ class _ReviewController(NSObject):
|
|
|
1761
1992
|
|
|
1762
1993
|
def windowShouldClose_(self, sender):
|
|
1763
1994
|
# Closing the window stops review; remaining cards are left undecided
|
|
1764
|
-
# (not posted). Finish with whatever was decided so far.
|
|
1995
|
+
# (not posted). Finish with whatever was decided so far. The menu bar
|
|
1996
|
+
# treats a close with undecided drafts as SNOOZE (they re-present after
|
|
1997
|
+
# REVIEW_SNOOZE_SECONDS, or sooner via the menu), whether it came from
|
|
1998
|
+
# the cross or the title-bar Snooze button.
|
|
1765
1999
|
self._finish()
|
|
1766
2000
|
return True
|
|
1767
2001
|
|
|
@@ -1783,11 +2017,12 @@ class _ReviewController(NSObject):
|
|
|
1783
2017
|
except Exception:
|
|
1784
2018
|
pass
|
|
1785
2019
|
_active = None
|
|
1786
|
-
|
|
1787
|
-
|
|
2020
|
+
reason = self._close_reason or "closed"
|
|
2021
|
+
_log(f"closed: {len(self._decisions)} decided of {len(self._drafts)} ({reason})")
|
|
2022
|
+
_write_review_state(last_event="snoozed" if reason == "snooze" else "closed")
|
|
1788
2023
|
|
|
1789
2024
|
|
|
1790
|
-
def present_review(drafts, on_decision=None, on_complete=None):
|
|
2025
|
+
def present_review(drafts, on_decision=None, on_complete=None, focus=False):
|
|
1791
2026
|
"""Show the review cards (main thread only). drafts: list of
|
|
1792
2027
|
{n, thread_author, thread_text, reply_text, link_url, thread_url?, stats?}
|
|
1793
2028
|
where stats is the discovery-time candidate snapshot
|
|
@@ -1809,14 +2044,17 @@ def present_review(drafts, on_decision=None, on_complete=None):
|
|
|
1809
2044
|
active experiment/scenario arm.
|
|
1810
2045
|
on_decision(decision) fires the instant each card is approved/rejected (so an
|
|
1811
2046
|
approved draft posts right away); on_complete(decisions) fires when the user
|
|
1812
|
-
finishes the last card or closes the window. Both run on the main thread.
|
|
2047
|
+
finishes the last card or closes the window. Both run on the main thread.
|
|
2048
|
+
focus=True (user-initiated open, e.g. the menu bar's "Review N pending
|
|
2049
|
+
drafts") activates the app and seats the caret in the reply field; the
|
|
2050
|
+
default False shows the card without taking keyboard focus."""
|
|
1813
2051
|
global _active
|
|
1814
2052
|
if not drafts:
|
|
1815
2053
|
if on_complete is not None:
|
|
1816
2054
|
on_complete([])
|
|
1817
2055
|
return
|
|
1818
|
-
_active = _ReviewController.alloc().
|
|
1819
|
-
drafts, on_decision, on_complete
|
|
2056
|
+
_active = _ReviewController.alloc().initWithDrafts_onDecision_onComplete_focus_(
|
|
2057
|
+
drafts, on_decision, on_complete, focus
|
|
1820
2058
|
)
|
|
1821
2059
|
|
|
1822
2060
|
|
|
@@ -1832,6 +2070,22 @@ def extend_active(drafts):
|
|
|
1832
2070
|
return 0
|
|
1833
2071
|
|
|
1834
2072
|
|
|
2073
|
+
def prune_active(ns):
|
|
2074
|
+
"""Remove not-yet-reached drafts (by plan index `n`) from the open review
|
|
2075
|
+
card, if one is up -- e.g. a card that expired on the backend mid-review
|
|
2076
|
+
(see merge_review_queue.py's backend sync). Never touches the card
|
|
2077
|
+
currently on screen or any already-decided one, so nothing visible is
|
|
2078
|
+
yanked out from under the user. Returns the count actually removed (0 if
|
|
2079
|
+
no card is open or none of `ns` are still ahead in the stack). Main thread
|
|
2080
|
+
only (called from the menu bar's rumps timer)."""
|
|
2081
|
+
if _active is None or not ns:
|
|
2082
|
+
return 0
|
|
2083
|
+
try:
|
|
2084
|
+
return _active.prune_drafts(ns)
|
|
2085
|
+
except Exception:
|
|
2086
|
+
return 0
|
|
2087
|
+
|
|
2088
|
+
|
|
1835
2089
|
def active_status():
|
|
1836
2090
|
"""Live review-surface snapshot for the menu bar's unattended-review
|
|
1837
2091
|
watchdog, or None when no card is open. Main thread only."""
|
|
@@ -1913,6 +2167,20 @@ def heal_active():
|
|
|
1913
2167
|
return False
|
|
1914
2168
|
|
|
1915
2169
|
|
|
2170
|
+
def focus_active():
|
|
2171
|
+
"""Bring the open card to the user deliberately (menu 'Review pending
|
|
2172
|
+
drafts' while a card is already up): activate + caret in the reply field.
|
|
2173
|
+
Main thread only. Returns True if a card was focused."""
|
|
2174
|
+
c = _active
|
|
2175
|
+
if c is None or c._panel is None:
|
|
2176
|
+
return False
|
|
2177
|
+
try:
|
|
2178
|
+
c.focusReply_(None)
|
|
2179
|
+
return True
|
|
2180
|
+
except Exception:
|
|
2181
|
+
return False
|
|
2182
|
+
|
|
2183
|
+
|
|
1916
2184
|
# ---- overall-feedback composer ----------------------------------------------
|
|
1917
2185
|
# One small floating panel with a free-text field, for guidance that is about
|
|
1918
2186
|
# the PIPELINE rather than any single draft ("less shilling", "more dev
|
|
@@ -1940,6 +2208,21 @@ def set_feedback_handler(cb):
|
|
|
1940
2208
|
_feedback_handler = cb
|
|
1941
2209
|
|
|
1942
2210
|
|
|
2211
|
+
# Bulk-discard hook for the title bar's "Discard all…" button, same
|
|
2212
|
+
# registration pattern as _feedback_handler above. The menu bar registers
|
|
2213
|
+
# _discard_all_pending here (it owns the confirmation alert, the store flip,
|
|
2214
|
+
# and dismissing the panel). Cards built while this is None simply don't get
|
|
2215
|
+
# the button.
|
|
2216
|
+
_discard_all_handler = None
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
def set_discard_all_handler(cb):
|
|
2220
|
+
"""Register the title-bar "Discard all…" action. The menu bar calls this
|
|
2221
|
+
before presenting cards with its bulk-discard handler."""
|
|
2222
|
+
global _discard_all_handler
|
|
2223
|
+
_discard_all_handler = cb
|
|
2224
|
+
|
|
2225
|
+
|
|
1943
2226
|
def _feedback_frame():
|
|
1944
2227
|
"""Just below the open review card when there is one (so it never covers
|
|
1945
2228
|
the card being reviewed), else the top-right corner of the pointer's
|
|
@@ -165,6 +165,38 @@ def _loop():
|
|
|
165
165
|
pass
|
|
166
166
|
|
|
167
167
|
|
|
168
|
+
def emit(line, context="menubar"):
|
|
169
|
+
"""Buffer ONE structured line for the relay under a caller-chosen context
|
|
170
|
+
(e.g. "browser-foreground"), and mirror it to the real stderr so the local
|
|
171
|
+
menubar.err.log keeps a copy. Bypasses the _Tee so the line is not ALSO
|
|
172
|
+
buffered under the default "menubar" context. Best-effort; never raises."""
|
|
173
|
+
try:
|
|
174
|
+
t = str(line).strip()
|
|
175
|
+
if not t or _BASE64_BLOB_RE.match(t):
|
|
176
|
+
return
|
|
177
|
+
real = sys.stderr._real if isinstance(sys.stderr, _Tee) else sys.stderr
|
|
178
|
+
try:
|
|
179
|
+
real.write(t + "\n")
|
|
180
|
+
real.flush()
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
if not ENABLED:
|
|
184
|
+
return
|
|
185
|
+
with _lock:
|
|
186
|
+
_buf.append(
|
|
187
|
+
{
|
|
188
|
+
"ts": _now_iso(),
|
|
189
|
+
"stream": "stdout",
|
|
190
|
+
"line": t[:MAX_LINE_LEN],
|
|
191
|
+
"context": str(context or "menubar")[:200],
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
if len(_buf) > MAX_BUFFER:
|
|
195
|
+
del _buf[: len(_buf) - MAX_BUFFER]
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
|
|
168
200
|
def install():
|
|
169
201
|
"""Wrap sys.stderr with the relay tee and start the background flusher.
|
|
170
202
|
Call once at menubar boot, after the S4L_REPO_DIR sys.path insertion."""
|