@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.
@@ -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
- # Inline approve row: emoji + tooltip per approval level (button tag =
153
- # level). Level 1 = plain approve; 2+ = loved=True on the decision, with the
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
- ("👍", "Approve"),
158
- ("😄", "Approve, really good pick"),
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 initWithDrafts_onDecision_onComplete_(self, drafts, on_decision, on_complete):
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
- panel.setBecomesKeyOnlyIfNeeded_(False) # let the reply field be edited
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.focusReply_(None)
795
- # App activation/key-window promotion lands on the run loop; do one
796
- # deferred pass so the first card is editable even when opened from the
797
- # status-bar timer while another app is frontmost.
798
- self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
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, 44, 30))
1080
+ btn = NSButton.alloc().initWithFrame_(NSMakeRect(x, H - 42, 38, 30))
888
1081
  btn.setTitle_(emoji)
889
1082
  btn.setBezelStyle_(NSBezelStyleRounded)
890
- # 13pt = the size Reject's title renders at; anything bigger
891
- # overflows the rounded bezel's ~22px content lane (16pt did).
892
- btn.setFont_(NSFont.systemFontOfSize_(13))
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 += 48
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 (not just the first) so each one is immediately editable.
1243
- self._panel.makeFirstResponder_(tv)
1244
- self.performSelector_withObject_afterDelay_("focusReply:", None, 0.05)
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
- _log(f"closed: {len(self._decisions)} decided of {len(self._drafts)}")
1787
- _write_review_state(last_event="closed")
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().initWithDrafts_onDecision_onComplete_(
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."""