usage-cli 0.29.32__py3-none-any.whl

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 (109) hide show
  1. adapters/__init__.py +5 -0
  2. adapters/agy.py +68 -0
  3. adapters/claude.py +215 -0
  4. adapters/codex.py +209 -0
  5. adapters/rate_limits.py +76 -0
  6. adapters/registry.py +17 -0
  7. adapters/types.py +139 -0
  8. agy_disk_cache.py +135 -0
  9. agy_loader.py +416 -0
  10. agy_quota_probe.py +748 -0
  11. agy_window_keeper.py +185 -0
  12. analyzer/__init__.py +5 -0
  13. analyzer/aggregator.py +139 -0
  14. analyzer/blocks.py +80 -0
  15. analyzer/diagnoser.py +638 -0
  16. analyzer/insights.py +277 -0
  17. analyzer/persona_loader.py +199 -0
  18. analyzer/reporter.py +989 -0
  19. analyzer/subscription.py +108 -0
  20. burn_rate.py +75 -0
  21. cache_quarantine.py +50 -0
  22. codex_disk_cache.py +227 -0
  23. codex_events.py +136 -0
  24. codex_fork_replay.py +111 -0
  25. codex_loader.py +1426 -0
  26. codex_paths.py +20 -0
  27. critter_frames.py +26 -0
  28. discussion_bridge.py +1196 -0
  29. discussion_cli.py +844 -0
  30. discussion_session.py +622 -0
  31. discussion_usage.py +13 -0
  32. discussion_window.py +955 -0
  33. disk_cache_common.py +132 -0
  34. disk_cache_lifecycle.py +39 -0
  35. doctor.py +452 -0
  36. fsevents_watch.py +207 -0
  37. history_disk_cache.py +110 -0
  38. history_loader.py +416 -0
  39. i18n.py +88 -0
  40. jsonl_limits.py +17 -0
  41. jsonl_utils.py +40 -0
  42. login_item.py +154 -0
  43. main.py +387 -0
  44. menubar.py +1201 -0
  45. menubar_actions.py +204 -0
  46. menubar_agy.py +193 -0
  47. menubar_chrome.py +156 -0
  48. menubar_menu.py +169 -0
  49. menubar_notify.py +102 -0
  50. menubar_popover.py +233 -0
  51. menubar_prefs.py +118 -0
  52. menubar_refresh.py +285 -0
  53. menubar_state.py +1200 -0
  54. menubar_title.py +157 -0
  55. menubar_update.py +123 -0
  56. panel_window.py +78 -0
  57. panel_window_state.py +159 -0
  58. panels/__init__.py +186 -0
  59. panels/base.py +83 -0
  60. panels/dynamic_height.py +140 -0
  61. panels/payload.py +178 -0
  62. panels/web_panel.py +513 -0
  63. panels/window_drag.py +56 -0
  64. prefs.py +44 -0
  65. pricing.py +452 -0
  66. project_resolver.py +112 -0
  67. service_status.py +383 -0
  68. session_hooks.py +1154 -0
  69. setup_app.py +171 -0
  70. setup_hook.py +1011 -0
  71. statusline_settings.py +160 -0
  72. talent_market_bridge.py +243 -0
  73. time_utils.py +24 -0
  74. tui.py +288 -0
  75. tui_sprite.py +206 -0
  76. ui/__init__.py +5 -0
  77. ui/html_report.py +923 -0
  78. ui/report_scripts.py +251 -0
  79. ui/report_styles.py +370 -0
  80. ui/tables.py +888 -0
  81. update_checker.py +156 -0
  82. update_gate.py +66 -0
  83. update_release_notes.py +49 -0
  84. usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
  85. usage_cli-0.29.32.dist-info/METADATA +223 -0
  86. usage_cli-0.29.32.dist-info/RECORD +109 -0
  87. usage_cli-0.29.32.dist-info/WHEEL +5 -0
  88. usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
  89. usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
  90. usage_cli-0.29.32.dist-info/top_level.txt +80 -0
  91. usage_cli.py +827 -0
  92. usage_client.py +487 -0
  93. usage_diagnosis_snapshot.py +143 -0
  94. usage_dir_sweeper.py +100 -0
  95. usage_lang.py +79 -0
  96. usage_logging.py +75 -0
  97. usage_notifications.py +96 -0
  98. usage_rate.py +97 -0
  99. usage_session_resume.py +913 -0
  100. usage_statusline.py +810 -0
  101. usage_statusline_agy.py +397 -0
  102. usage_statusline_forwarder.py +88 -0
  103. usage_terse_mode.py +223 -0
  104. usage_terse_reminder.py +151 -0
  105. win_login_item.py +53 -0
  106. window_keeper.py +264 -0
  107. windows_watch.py +443 -0
  108. wintray.py +2014 -0
  109. wintray_menu.py +136 -0
discussion_window.py ADDED
@@ -0,0 +1,955 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-only
2
+ # Copyright (C) 2026 lollapalooza <https://github.com/aqua5230>
3
+ #
4
+ # Part of "usage". Free software licensed under the GNU Affero General Public
5
+ # License v3.0 only; see the LICENSE file for full terms and the warranty disclaimer.
6
+
7
+ """macOS window shell for the PyObjC-free AI council bridge."""
8
+
9
+ # This module is imported and type-checked on Windows CI too (its non-GUI
10
+ # helpers are tested there), but its AppKit/WebKit imports and classes are
11
+ # gated behind `if sys.platform == "darwin":`. mypy's platform narrowing
12
+ # statically skips that block on a win32 run, so every name it would have
13
+ # bound (NSWindow, WKWebView, _DiscussionWindow, ...) looks undefined to
14
+ # methods that reference them elsewhere in the file — hence `name-defined`
15
+ # joining the existing PyObjC-stub suppressions below.
16
+ # mypy: disable-error-code="import-untyped,import-not-found,misc,name-defined"
17
+ from __future__ import annotations
18
+
19
+ import base64
20
+ import contextlib
21
+ import json
22
+ import os
23
+ import shutil
24
+ import sys
25
+ import threading
26
+ import time
27
+ from collections.abc import Callable, Mapping, Sequence
28
+ from dataclasses import asdict, dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any, Literal, cast
31
+
32
+ from discussion_bridge import DiscussionBridge, ParticipantSpec
33
+ from discussion_cli import DetectionResult
34
+ from discussion_session import DebateStyle
35
+ from i18n import _load_i18n_bundle, _t, packaged_resource_path
36
+ from panels.payload import _data_uri
37
+ from talent_market_bridge import list_personas, pick_folder, pick_image_file
38
+ from usage_lang import detect_lang
39
+
40
+ ATTACHMENTS_DIR = Path(os.path.expanduser("~/.usage/discussion_attachments"))
41
+ ATTACHMENT_MAX_FILES = 50
42
+ ATTACHMENT_SUFFIXES = (".png", ".jpg", ".jpeg", ".gif", ".webp")
43
+ DROP_MAX_BYTES = 20 * 1024 * 1024
44
+ THUMBNAIL_MAX_PIXELS = 128
45
+
46
+
47
+ def _attachment_timestamp() -> str:
48
+ return time.strftime("%Y%m%d-%H%M%S")
49
+
50
+
51
+ def build_attachment_name(stamp: str, index: int, suffix: str) -> str:
52
+ """Pure filename builder so naming is testable without touching disk."""
53
+ return f"{stamp}-{index}{suffix}"
54
+
55
+
56
+ def _next_attachment_path(suffix: str, directory: Path) -> Path:
57
+ stamp = _attachment_timestamp()
58
+ index = 1
59
+ while True:
60
+ candidate = directory / build_attachment_name(stamp, index, suffix)
61
+ if not candidate.exists():
62
+ return candidate
63
+ index += 1
64
+
65
+
66
+ def save_attachment_bytes(
67
+ data: bytes,
68
+ suffix: str,
69
+ directory: Path = ATTACHMENTS_DIR,
70
+ ) -> Path:
71
+ """Persist raw image bytes under the managed directory and prune old files."""
72
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
73
+ target = _next_attachment_path(suffix, directory)
74
+ target.write_bytes(data)
75
+ target.chmod(0o600)
76
+ prune_attachments(directory=directory)
77
+ return target
78
+
79
+
80
+ def import_attachment_file(
81
+ src: str,
82
+ directory: Path = ATTACHMENTS_DIR,
83
+ ) -> Path | None:
84
+ """Copy a user-picked image into the managed directory; None on bad input."""
85
+ path = Path(src)
86
+ if not path.is_file() or path.suffix.lower() not in ATTACHMENT_SUFFIXES:
87
+ return None
88
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
89
+ target = _next_attachment_path(path.suffix.lower(), directory)
90
+ shutil.copy(path, target)
91
+ target.chmod(0o600)
92
+ prune_attachments(directory=directory)
93
+ return target
94
+
95
+
96
+ def attachment_thumbnail_data_uri(path: Path) -> str | None:
97
+ """Return a small PNG data URI for a managed image, or None on failure."""
98
+ if sys.platform != "darwin" or not path.is_file():
99
+ return None
100
+ try:
101
+ from AppKit import NSBitmapImageFileTypePNG, NSBitmapImageRep, NSImage, NSMakeSize
102
+
103
+ image = NSImage.alloc().initWithContentsOfFile_(str(path))
104
+ if image is None:
105
+ return None
106
+ size = image.size()
107
+ longest = max(float(size.width), float(size.height))
108
+ if longest <= 0:
109
+ return None
110
+ scale = min(1.0, THUMBNAIL_MAX_PIXELS / longest)
111
+ image.setSize_(NSMakeSize(size.width * scale, size.height * scale))
112
+ tiff = image.TIFFRepresentation()
113
+ representation = NSBitmapImageRep.imageRepWithData_(tiff)
114
+ png = representation.representationUsingType_properties_(
115
+ NSBitmapImageFileTypePNG, {}
116
+ )
117
+ if png is None:
118
+ return None
119
+ return "data:image/png;base64," + base64.b64encode(bytes(png)).decode("ascii")
120
+ except Exception:
121
+ return None
122
+
123
+
124
+ def prune_attachments(
125
+ directory: Path = ATTACHMENTS_DIR,
126
+ keep: int = ATTACHMENT_MAX_FILES,
127
+ ) -> None:
128
+ """Keep only the newest ``keep`` files, deleting the oldest by mtime."""
129
+ if not directory.exists() or keep < 0:
130
+ return
131
+ files = [entry for entry in directory.iterdir() if entry.is_file()]
132
+ if len(files) <= keep:
133
+ return
134
+ files.sort(key=lambda entry: entry.stat().st_mtime)
135
+ for stale in files[: len(files) - keep]:
136
+ with contextlib.suppress(OSError):
137
+ stale.unlink()
138
+
139
+
140
+ def read_pasteboard_image() -> tuple[bytes, str] | None:
141
+ """Read an image from the macOS pasteboard; return (bytes, suffix) or None.
142
+
143
+ AppKit is imported lazily so the module loads on non-darwin hosts; the call
144
+ itself only works on macOS. Screenshots land as TIFF, so both PNG and TIFF
145
+ pasteboard types are handled, converting TIFF to PNG on the way to disk.
146
+ """
147
+ try:
148
+ from AppKit import (
149
+ NSBitmapImageFileTypePNG,
150
+ NSBitmapImageRep,
151
+ NSPasteboard,
152
+ NSPasteboardTypePNG,
153
+ NSPasteboardTypeTIFF,
154
+ )
155
+ except ImportError:
156
+ return None
157
+ pb = NSPasteboard.generalPasteboard()
158
+ if pb is None:
159
+ return None
160
+ types = pb.types()
161
+ if NSPasteboardTypePNG in types:
162
+ raw = pb.dataForType_(NSPasteboardTypePNG)
163
+ if raw:
164
+ return (bytes(raw), ".png")
165
+ if NSPasteboardTypeTIFF in types:
166
+ tiff = pb.dataForType_(NSPasteboardTypeTIFF)
167
+ if tiff:
168
+ rep = NSBitmapImageRep.imageRepWithData_(tiff)
169
+ if rep is not None:
170
+ png = rep.representationUsingType_properties_(
171
+ NSBitmapImageFileTypePNG, {}
172
+ )
173
+ if png:
174
+ return (bytes(png), ".png")
175
+ return None
176
+
177
+
178
+ SCRIPT_HANDLER_NAME = "usageDiscussion"
179
+ WINDOW_AUTOSAVE_NAME = "usage.discussion.window"
180
+ BUILTIN_PARTICIPANTS = ("claude", "codex", "agy")
181
+ PARTICIPANT_LABELS = {
182
+ "claude": "Claude",
183
+ "codex": "Codex",
184
+ "agy": "Antigravity",
185
+ }
186
+ ALLOWED_MODELS: dict[str, frozenset[str]] = {
187
+ "claude": frozenset({"opus", "sonnet", "haiku"}),
188
+ "codex": frozenset({"gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-sol"}),
189
+ "agy": frozenset({"gemini-3.6-flash-high", "gemini-3.1-pro-high"}),
190
+ }
191
+ RUNNING_STATUSES = frozenset(
192
+ {"PREPARING", "ROUND1_RUNNING", "ROUND2_RUNNING", "SUMMARIZING", "CANCELLING"}
193
+ )
194
+
195
+ if sys.platform == "darwin":
196
+ import objc
197
+ from AppKit import (
198
+ NSApp,
199
+ NSApplicationActivateAllWindows,
200
+ NSApplicationActivateIgnoringOtherApps,
201
+ NSBackingStoreBuffered,
202
+ NSMakeRect,
203
+ NSViewHeightSizable,
204
+ NSViewWidthSizable,
205
+ NSWindow,
206
+ NSWindowStyleMaskClosable,
207
+ NSWindowStyleMaskMiniaturizable,
208
+ NSWindowStyleMaskResizable,
209
+ NSWindowStyleMaskTitled,
210
+ )
211
+ from Foundation import NSURL, NSObject, NSRunningApplication, NSThread
212
+
213
+ try:
214
+ from WebKit import WKUserContentController, WKWebView, WKWebViewConfiguration
215
+ except ModuleNotFoundError:
216
+ with objc.autorelease_pool():
217
+ objc.loadBundle(
218
+ "WebKit",
219
+ globals(),
220
+ bundle_path="/System/Library/Frameworks/WebKit.framework",
221
+ )
222
+
223
+ objc.registerMetaDataForSelector(
224
+ b"WKWebView",
225
+ b"evaluateJavaScript:completionHandler:",
226
+ {
227
+ "arguments": {
228
+ 3: {
229
+ "callable": {
230
+ "retval": {"type": b"v"},
231
+ "arguments": {
232
+ 0: {"type": b"^v"},
233
+ 1: {"type": b"@"},
234
+ 2: {"type": b"@"},
235
+ },
236
+ },
237
+ },
238
+ },
239
+ },
240
+ )
241
+
242
+
243
+ ActionName = Literal[
244
+ "discussion_attach",
245
+ "discussion_clear",
246
+ "discussion_detect",
247
+ "discussion_pick_folder",
248
+ "discussion_clear_folder",
249
+ "discussion_start",
250
+ "discussion_stop",
251
+ "discussion_paste_image",
252
+ "discussion_pick_image",
253
+ "discussion_drop_image",
254
+ "discussion_remove_attachment",
255
+ "discussion_submit_guidance",
256
+ ]
257
+
258
+
259
+ @dataclass(frozen=True)
260
+ class DiscussionAction:
261
+ action: ActionName
262
+ topic: str | None = None
263
+ participants: tuple[str, ...] = ()
264
+ moderator_id: str | None = None
265
+ working_directory: str | None = None
266
+ attachments: tuple[str, ...] = ()
267
+ total_rounds: int = 2
268
+ include_summary: bool = True
269
+ end_on_consensus: bool = False
270
+ guidance_between_rounds: bool = False
271
+ models: Mapping[str, str | None] = field(default_factory=dict)
272
+ personas: Mapping[str, str | None] = field(default_factory=dict)
273
+ debate_style: DebateStyle = DebateStyle.CONSTRUCTIVE
274
+ attachment_path: str | None = None
275
+ attachment_data: str | None = None
276
+ attachment_name: str | None = None
277
+ guidance_text: str | None = None
278
+
279
+
280
+ def parse_discussion_action(raw: object) -> DiscussionAction:
281
+ """Validate one JSON-string action without touching PyObjC or the bridge."""
282
+ if not isinstance(raw, str):
283
+ raise ValueError("action message must be a JSON string")
284
+ try:
285
+ payload = json.loads(raw)
286
+ except (json.JSONDecodeError, TypeError) as exc:
287
+ raise ValueError("action message is not valid JSON") from exc
288
+ if not isinstance(payload, dict):
289
+ raise ValueError("action message must contain an object")
290
+ action = payload.get("action")
291
+ if action not in {
292
+ "discussion_attach",
293
+ "discussion_clear",
294
+ "discussion_detect",
295
+ "discussion_pick_folder",
296
+ "discussion_clear_folder",
297
+ "discussion_start",
298
+ "discussion_stop",
299
+ "discussion_paste_image",
300
+ "discussion_pick_image",
301
+ "discussion_drop_image",
302
+ "discussion_remove_attachment",
303
+ "discussion_submit_guidance",
304
+ }:
305
+ raise ValueError("unknown discussion action")
306
+ if action == "discussion_submit_guidance":
307
+ text_value = payload.get("text")
308
+ if not isinstance(text_value, str):
309
+ raise ValueError("discussion_submit_guidance requires a string text")
310
+ return DiscussionAction(
311
+ cast(ActionName, action),
312
+ guidance_text=text_value,
313
+ )
314
+ if action == "discussion_remove_attachment":
315
+ path_value = payload.get("path")
316
+ if not isinstance(path_value, str) or not path_value.strip():
317
+ raise ValueError("discussion_remove_attachment requires a string path")
318
+ return DiscussionAction(
319
+ cast(ActionName, action),
320
+ attachment_path=path_value,
321
+ )
322
+ if action == "discussion_drop_image":
323
+ data_value = payload.get("data")
324
+ name_value = payload.get("name")
325
+ if not isinstance(data_value, str) or not data_value.strip():
326
+ raise ValueError("discussion_drop_image requires base64 data")
327
+ if not isinstance(name_value, str) or not name_value.strip():
328
+ raise ValueError("discussion_drop_image requires a filename")
329
+ return DiscussionAction(
330
+ cast(ActionName, action),
331
+ attachment_data=data_value,
332
+ attachment_name=name_value,
333
+ )
334
+ if action != "discussion_start":
335
+ return DiscussionAction(cast(ActionName, action))
336
+
337
+ topic = payload.get("topic")
338
+ participant_value = payload.get("participants")
339
+ moderator_value = payload.get("moderatorId")
340
+ working_directory_value = payload.get("workingDir")
341
+ if not isinstance(topic, str):
342
+ raise ValueError("discussion_start requires a string topic")
343
+ if not isinstance(participant_value, list) or not participant_value:
344
+ raise ValueError("discussion_start requires at least one participant")
345
+ if not all(isinstance(item, str) for item in participant_value):
346
+ raise ValueError("discussion_start participants must be strings")
347
+ participants = tuple(cast(list[str], participant_value))
348
+ if len(participants) != len(set(participants)):
349
+ raise ValueError("discussion_start participants must be unique")
350
+ if any(item not in BUILTIN_PARTICIPANTS for item in participants):
351
+ raise ValueError("discussion_start contains an unknown participant")
352
+ if moderator_value is not None and not isinstance(moderator_value, str):
353
+ raise ValueError("discussion_start moderatorId must be a string or null")
354
+ if working_directory_value is not None and not isinstance(
355
+ working_directory_value, str
356
+ ):
357
+ raise ValueError("discussion_start workingDir must be a string or null")
358
+ attachments_value = payload.get("attachments")
359
+ rounds_value = payload.get("totalRounds", 2)
360
+ include_summary_value = payload.get("includeSummary", True)
361
+ end_on_consensus_value = payload.get("endOnConsensus", False)
362
+ guidance_between_rounds_value = payload.get("guidanceBetweenRounds", False)
363
+ debate_style_value = payload.get("debateStyle", DebateStyle.CONSTRUCTIVE.value)
364
+ if not isinstance(rounds_value, int) or isinstance(rounds_value, bool):
365
+ raise ValueError("discussion_start totalRounds must be an integer")
366
+ if not isinstance(include_summary_value, bool):
367
+ raise ValueError("discussion_start includeSummary must be a boolean")
368
+ if not isinstance(end_on_consensus_value, bool):
369
+ raise ValueError("discussion_start endOnConsensus must be a boolean")
370
+ if not isinstance(guidance_between_rounds_value, bool):
371
+ raise ValueError("discussion_start guidanceBetweenRounds must be a boolean")
372
+ if not isinstance(debate_style_value, str):
373
+ raise ValueError("discussion_start debateStyle must be a string")
374
+ try:
375
+ debate_style = DebateStyle(debate_style_value)
376
+ except ValueError as exc:
377
+ raise ValueError("discussion_start has an unknown debateStyle") from exc
378
+ if attachments_value is not None:
379
+ if not isinstance(attachments_value, list) or not all(
380
+ isinstance(item, str) for item in attachments_value
381
+ ):
382
+ raise ValueError("discussion_start attachments must be a list of strings")
383
+ attachments = tuple(cast(list[str], attachments_value))
384
+ else:
385
+ attachments = ()
386
+ moderator_id = moderator_value
387
+ if moderator_id is not None and moderator_id not in participants:
388
+ raise ValueError("discussion_start moderatorId must be selected")
389
+ models = _parse_discussion_models(payload.get("models"))
390
+ personas = _parse_discussion_personas(payload.get("personas"))
391
+ return DiscussionAction(
392
+ cast(ActionName, action),
393
+ topic=topic,
394
+ participants=participants,
395
+ moderator_id=moderator_id,
396
+ working_directory=working_directory_value or None,
397
+ attachments=attachments,
398
+ total_rounds=min(5, max(1, rounds_value)),
399
+ include_summary=include_summary_value,
400
+ end_on_consensus=end_on_consensus_value,
401
+ guidance_between_rounds=guidance_between_rounds_value,
402
+ models=models,
403
+ personas=personas,
404
+ debate_style=debate_style,
405
+ )
406
+
407
+
408
+ def _parse_discussion_models(raw: object) -> dict[str, str | None]:
409
+ """Validate the optional per-participant model map; absent means all default."""
410
+ if raw is None:
411
+ return {}
412
+ if not isinstance(raw, dict):
413
+ raise ValueError("discussion_start models must be an object")
414
+ models: dict[str, str | None] = {}
415
+ for key, value in raw.items():
416
+ if key not in BUILTIN_PARTICIPANTS:
417
+ raise ValueError("discussion_start models has an unknown participant")
418
+ if value is None or value == "":
419
+ models[key] = None
420
+ elif isinstance(value, str):
421
+ if value not in ALLOWED_MODELS[key]:
422
+ raise ValueError("discussion_start models has an unknown model")
423
+ models[key] = value
424
+ else:
425
+ raise ValueError("discussion_start models values must be strings or null")
426
+ return models
427
+
428
+
429
+ def _parse_discussion_personas(raw: object) -> dict[str, str | None]:
430
+ """Validate the optional per-participant persona map; absent means neutral."""
431
+ if raw is None:
432
+ return {}
433
+ if not isinstance(raw, dict):
434
+ raise ValueError("discussion_start personas must be an object")
435
+ personas: dict[str, str | None] = {}
436
+ for key, value in raw.items():
437
+ if key not in BUILTIN_PARTICIPANTS:
438
+ raise ValueError("discussion_start personas has an unknown participant")
439
+ if value is None or value == "":
440
+ personas[key] = None
441
+ elif isinstance(value, str):
442
+ personas[key] = value
443
+ else:
444
+ raise ValueError("discussion_start personas values must be strings or null")
445
+ return personas
446
+
447
+
448
+ def serialize_javascript_call(function_name: str, payload: object) -> str:
449
+ encoded = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
450
+ return f"window.{function_name}({encoded})"
451
+
452
+
453
+ def serialize_event_batch(
454
+ events: Sequence[Mapping[str, object]],
455
+ snapshot: Mapping[str, object],
456
+ ) -> str:
457
+ """Add snapshot-only turn metadata before sending one JavaScript batch."""
458
+ turn_streaming: dict[str, bool] = {}
459
+ turns = snapshot.get("turns")
460
+ if isinstance(turns, list):
461
+ for item in turns:
462
+ if not isinstance(item, dict):
463
+ continue
464
+ turn_id = item.get("id")
465
+ supports_stream = item.get("supports_token_stream")
466
+ if isinstance(turn_id, str) and isinstance(supports_stream, bool):
467
+ turn_streaming[turn_id] = supports_stream
468
+
469
+ enriched: list[dict[str, object]] = []
470
+ for source_event in events:
471
+ event = dict(source_event)
472
+ turn_id = event.get("turn_id")
473
+ payload_value = event.get("payload")
474
+ payload = dict(payload_value) if isinstance(payload_value, dict) else {}
475
+ if isinstance(turn_id, str) and turn_id in turn_streaming:
476
+ payload["supports_token_stream"] = turn_streaming[turn_id]
477
+ event["payload"] = payload
478
+ enriched.append(event)
479
+ return serialize_javascript_call("discussionApplyEvents", enriched)
480
+
481
+
482
+ def _load_discussion_html(language: str | None = None) -> str:
483
+ path = packaged_resource_path(
484
+ "windows/discussion.html",
485
+ Path(__file__).with_name("assets") / "windows" / "discussion.html",
486
+ )
487
+ html = path.read_text(encoding="utf-8")
488
+ return (
489
+ html.replace("{{CLAUDE_ICON}}", _data_uri("claude.webp"))
490
+ .replace("{{CODEX_ICON}}", _data_uri("codex.webp"))
491
+ .replace(
492
+ "{{I18N_BUNDLE}}",
493
+ json.dumps(_load_i18n_bundle(), ensure_ascii=False),
494
+ )
495
+ .replace(
496
+ "{{INITIAL_LANGUAGE}}",
497
+ json.dumps(language or detect_lang()),
498
+ )
499
+ )
500
+
501
+
502
+ if sys.platform == "darwin":
503
+
504
+ class _DiscussionWindow(NSWindow):
505
+ def canBecomeMainWindow(self) -> bool:
506
+ return True
507
+
508
+ def canBecomeKeyWindow(self) -> bool:
509
+ return True
510
+
511
+
512
+ class _DiscussionScriptHandler(NSObject):
513
+ controller = objc.ivar()
514
+
515
+ def initWithController_(self, controller: Any) -> Any:
516
+ self = objc.super(_DiscussionScriptHandler, self).init()
517
+ if self is None:
518
+ return None
519
+ self.controller = controller
520
+ return self
521
+
522
+ def userContentController_didReceiveScriptMessage_(
523
+ self,
524
+ user_content_controller: Any,
525
+ message: Any,
526
+ ) -> None:
527
+ self.controller._receive_action(message.body())
528
+
529
+
530
+ class _DiscussionWindowDelegate(NSObject):
531
+ controller = objc.ivar()
532
+
533
+ def initWithController_(self, controller: Any) -> Any:
534
+ self = objc.super(_DiscussionWindowDelegate, self).init()
535
+ if self is None:
536
+ return None
537
+ self.controller = controller
538
+ return self
539
+
540
+ def windowWillClose_(self, notification: Any) -> None:
541
+ self.controller._detach()
542
+
543
+ def webView_didFinishNavigation_(self, webview: Any, navigation: Any) -> None:
544
+ self.controller._webview_did_finish()
545
+
546
+
547
+ class _MainThreadDispatcher(NSObject):
548
+ controller = objc.ivar()
549
+
550
+ def initWithController_(self, controller: Any) -> Any:
551
+ self = objc.super(_MainThreadDispatcher, self).init()
552
+ if self is None:
553
+ return None
554
+ self.controller = controller
555
+ return self
556
+
557
+ def drainDiscussionEvents_(self, sender: Any) -> None:
558
+ self.controller._drain_events_on_main_thread()
559
+
560
+
561
+ class DiscussionWindowController:
562
+ """Own the standalone NSWindow and forward bridge state to its web view."""
563
+
564
+ def __init__(self, bridge: DiscussionBridge | None = None) -> None:
565
+ self.bridge = bridge or DiscussionBridge()
566
+ self.window: Any | None = None
567
+ self.webview: Any | None = None
568
+ self._content_controller: Any | None = None
569
+ self._script_handler: Any | None = None
570
+ self._window_delegate: Any | None = None
571
+ self._dispatcher: Any | None = None
572
+ self._attached = False
573
+ self._web_ready = False
574
+ self._shutdown = False
575
+ self._drain_scheduled = False
576
+ self._drain_lock = threading.Lock()
577
+ self._language = detect_lang()
578
+ snapshot = self.bridge.snapshot()
579
+ snapshot_working_directory = snapshot.get("working_directory")
580
+ self._working_directory = (
581
+ snapshot_working_directory
582
+ if isinstance(snapshot_working_directory, str)
583
+ else None
584
+ )
585
+ self._attachments: list[dict[str, str]] = []
586
+ self._personas: list[dict[str, str]] = []
587
+ if sys.platform == "darwin":
588
+ self._dispatcher = _MainThreadDispatcher.alloc().initWithController_(self)
589
+
590
+ def show(self, close_popover: Callable[[], None] | None = None) -> None:
591
+ self._require_main_thread()
592
+ if self._shutdown:
593
+ raise RuntimeError("discussion window controller is shut down")
594
+ if close_popover is not None:
595
+ close_popover()
596
+ if self.window is None:
597
+ self._create_window()
598
+ window = self.window
599
+ assert window is not None
600
+ self._attach()
601
+ NSApp.activateIgnoringOtherApps_(True)
602
+ NSRunningApplication.currentApplication().activateWithOptions_(
603
+ NSApplicationActivateIgnoringOtherApps | NSApplicationActivateAllWindows
604
+ )
605
+ window.makeMainWindow()
606
+ window.makeKeyWindow()
607
+ window.makeKeyAndOrderFront_(None)
608
+ window.orderFrontRegardless()
609
+ if self._web_ready:
610
+ self._apply_full_state()
611
+ self._schedule_drain_on_main_thread()
612
+
613
+ def close(self) -> None:
614
+ self._require_main_thread()
615
+ if self.window is not None:
616
+ self.window.performClose_(None)
617
+
618
+ def shutdown(self, timeout_seconds: float = 5.0) -> None:
619
+ self._require_main_thread()
620
+ if self._shutdown:
621
+ return
622
+ self._shutdown = True
623
+ self._detach()
624
+ self.bridge.shutdown(timeout_seconds)
625
+ if self._content_controller is not None:
626
+ self._content_controller.removeScriptMessageHandlerForName_(SCRIPT_HANDLER_NAME)
627
+ if self.webview is not None:
628
+ self.webview.setNavigationDelegate_(None)
629
+ self.webview.stopLoading()
630
+ if self.window is not None:
631
+ self.window.setDelegate_(None)
632
+ self.window.orderOut_(None)
633
+ self.webview = None
634
+ self.window = None
635
+ self._content_controller = None
636
+ self._script_handler = None
637
+ self._window_delegate = None
638
+
639
+ def _create_window(self) -> None:
640
+ style = (
641
+ NSWindowStyleMaskTitled
642
+ | NSWindowStyleMaskClosable
643
+ | NSWindowStyleMaskMiniaturizable
644
+ | NSWindowStyleMaskResizable
645
+ )
646
+ self.window = _DiscussionWindow.alloc().initWithContentRect_styleMask_backing_defer_(
647
+ NSMakeRect(0, 0, 900, 640),
648
+ style,
649
+ NSBackingStoreBuffered,
650
+ False,
651
+ )
652
+ self.window.setTitle_(_t(self._language, "discussion_window_title"))
653
+ self.window.setReleasedWhenClosed_(False)
654
+ self.window.setFrameAutosaveName_(WINDOW_AUTOSAVE_NAME)
655
+ self._window_delegate = _DiscussionWindowDelegate.alloc().initWithController_(self)
656
+ self.window.setDelegate_(self._window_delegate)
657
+ self.window.center()
658
+
659
+ configuration = WKWebViewConfiguration.alloc().init()
660
+ self._content_controller = WKUserContentController.alloc().init()
661
+ self._script_handler = _DiscussionScriptHandler.alloc().initWithController_(self)
662
+ self._content_controller.addScriptMessageHandler_name_(
663
+ self._script_handler,
664
+ SCRIPT_HANDLER_NAME,
665
+ )
666
+ configuration.setUserContentController_(self._content_controller)
667
+ self.webview = WKWebView.alloc().initWithFrame_configuration_(
668
+ self.window.contentView().bounds(),
669
+ configuration,
670
+ )
671
+ self.webview.setAutoresizingMask_(NSViewWidthSizable | NSViewHeightSizable)
672
+ self.webview.setNavigationDelegate_(self._window_delegate)
673
+ self.window.setContentView_(self.webview)
674
+ html_path = packaged_resource_path(
675
+ "windows/discussion.html",
676
+ Path(__file__).with_name("assets") / "windows" / "discussion.html",
677
+ )
678
+ base_url = NSURL.fileURLWithPath_(str(html_path.parent))
679
+ self.webview.loadHTMLString_baseURL_(_load_discussion_html(self._language), base_url)
680
+
681
+ def _attach(self) -> None:
682
+ self._attached = True
683
+ self.bridge.set_event_listener(self._bridge_events_ready)
684
+
685
+ def _detach(self) -> None:
686
+ self._attached = False
687
+ self.bridge.set_event_listener(None)
688
+ with self._drain_lock:
689
+ self._drain_scheduled = False
690
+
691
+ def _bridge_events_ready(self) -> None:
692
+ if not self._attached or self._shutdown or self._dispatcher is None:
693
+ return
694
+ self._dispatcher.performSelectorOnMainThread_withObject_waitUntilDone_(
695
+ "drainDiscussionEvents:",
696
+ None,
697
+ False,
698
+ )
699
+
700
+ def _schedule_drain_on_main_thread(self) -> None:
701
+ self._require_main_thread()
702
+ if not self._attached or not self._web_ready or self.webview is None:
703
+ return
704
+ with self._drain_lock:
705
+ if self._drain_scheduled:
706
+ return
707
+ self._drain_scheduled = True
708
+ dispatcher = self._dispatcher
709
+ assert dispatcher is not None
710
+ dispatcher.performSelector_withObject_afterDelay_(
711
+ "drainDiscussionEvents:",
712
+ None,
713
+ 0.0,
714
+ )
715
+
716
+ def _drain_events_on_main_thread(self) -> None:
717
+ self._require_main_thread()
718
+ with self._drain_lock:
719
+ self._drain_scheduled = False
720
+ if not self._attached or not self._web_ready or self.webview is None:
721
+ return
722
+ events = self.bridge.drain_events(50)
723
+ if events:
724
+ script = serialize_event_batch(events, self.bridge.snapshot())
725
+ self.webview.evaluateJavaScript_completionHandler_(script, None)
726
+ if len(events) == 50:
727
+ self._schedule_drain_on_main_thread()
728
+
729
+ def _webview_did_finish(self) -> None:
730
+ self._require_main_thread()
731
+ if self._shutdown or self.webview is None:
732
+ return
733
+ self._web_ready = True
734
+ if self._attached:
735
+ self._apply_full_state()
736
+ self._schedule_drain_on_main_thread()
737
+
738
+ def _receive_action(self, raw: object) -> None:
739
+ self._require_main_thread()
740
+ try:
741
+ action = parse_discussion_action(raw)
742
+ if action.action == "discussion_attach":
743
+ self._apply_full_state()
744
+ elif action.action == "discussion_detect":
745
+ self._apply_detection()
746
+ elif action.action == "discussion_pick_folder":
747
+ selected = pick_folder()
748
+ if selected is not None:
749
+ self._working_directory = selected
750
+ self._apply_working_directory()
751
+ elif action.action == "discussion_clear_folder":
752
+ self._working_directory = None
753
+ self._apply_working_directory()
754
+ elif action.action == "discussion_paste_image":
755
+ self._handle_paste_image()
756
+ elif action.action == "discussion_drop_image":
757
+ assert action.attachment_data is not None
758
+ assert action.attachment_name is not None
759
+ self._handle_drop_image(action.attachment_data, action.attachment_name)
760
+ elif action.action == "discussion_pick_image":
761
+ self._handle_pick_image()
762
+ elif action.action == "discussion_remove_attachment":
763
+ assert action.attachment_path is not None
764
+ self._remove_attachment(action.attachment_path)
765
+ elif action.action == "discussion_stop":
766
+ self.bridge.stop()
767
+ self._apply_snapshot()
768
+ elif action.action == "discussion_submit_guidance":
769
+ assert action.guidance_text is not None
770
+ self.bridge.submit_guidance(action.guidance_text)
771
+ elif action.action == "discussion_clear":
772
+ result = self.bridge.clear()
773
+ if result.get("status") == "busy":
774
+ self._evaluate(
775
+ "discussionApplyError",
776
+ _t(self._language, "discussion_clear_busy"),
777
+ )
778
+ else:
779
+ self._apply_snapshot()
780
+ else:
781
+ assert action.topic is not None
782
+ personas_by_id = {
783
+ persona["id"]: persona for persona in self._personas
784
+ }
785
+ specs: list[ParticipantSpec] = []
786
+ for participant_id in action.participants:
787
+ persona_id = action.personas.get(participant_id)
788
+ persona = (
789
+ personas_by_id.get(persona_id)
790
+ if persona_id is not None
791
+ else None
792
+ )
793
+ specs.append(
794
+ ParticipantSpec(
795
+ id=participant_id,
796
+ label=PARTICIPANT_LABELS[participant_id],
797
+ adapter_id=participant_id,
798
+ model=action.models.get(participant_id),
799
+ persona_prompt=(
800
+ persona["system_prompt"] if persona else None
801
+ ),
802
+ persona_label=persona["name"] if persona else None,
803
+ )
804
+ )
805
+ self.bridge.start(
806
+ action.topic,
807
+ specs,
808
+ action.moderator_id,
809
+ working_directory=action.working_directory,
810
+ attachments=action.attachments,
811
+ total_rounds=action.total_rounds,
812
+ include_summary=action.include_summary,
813
+ end_on_consensus=action.end_on_consensus,
814
+ guidance_between_rounds=action.guidance_between_rounds,
815
+ debate_style=action.debate_style,
816
+ )
817
+ snapshot = self.bridge.snapshot()
818
+ snapshot_working_directory = snapshot.get("working_directory")
819
+ self._working_directory = (
820
+ snapshot_working_directory
821
+ if isinstance(snapshot_working_directory, str)
822
+ else None
823
+ )
824
+ self._apply_snapshot()
825
+ except Exception as exc:
826
+ self._evaluate("discussionApplyError", str(exc))
827
+
828
+ def _apply_full_state(self) -> None:
829
+ if not self._attached or not self._web_ready:
830
+ return
831
+ self._apply_snapshot()
832
+ self._apply_working_directory()
833
+ self._apply_detection()
834
+ self._apply_personas()
835
+ self._apply_attachments()
836
+
837
+ def _apply_snapshot(self) -> None:
838
+ self._evaluate("discussionApplySnapshot", self.bridge.snapshot())
839
+
840
+ def _apply_detection(self) -> None:
841
+ detections: list[DetectionResult] = self.bridge.detect_participants()
842
+ self._evaluate(
843
+ "discussionApplyDetection",
844
+ [asdict(detection) for detection in detections],
845
+ )
846
+
847
+ def _apply_personas(self) -> None:
848
+ self._personas = list_personas(self._language)
849
+ self._evaluate("discussionApplyPersonas", self._personas)
850
+
851
+ def _apply_working_directory(self) -> None:
852
+ self._evaluate("discussionApplyWorkingDir", self._working_directory)
853
+
854
+ def _apply_attachments(self, hint: str | None = None) -> None:
855
+ self._evaluate(
856
+ "discussionApplyAttachments",
857
+ {"attachments": list(self._attachments), "hint": hint},
858
+ )
859
+
860
+ def _handle_paste_image(self) -> None:
861
+ result = read_pasteboard_image()
862
+ if result is None:
863
+ self._apply_attachments(
864
+ hint=_t(self._language, "discussion_paste_no_image")
865
+ )
866
+ return
867
+ data, suffix = result
868
+ if len(data) > DROP_MAX_BYTES:
869
+ self._apply_attachments(
870
+ hint=_t(self._language, "discussion_drop_too_large", name="")
871
+ )
872
+ return
873
+ try:
874
+ target = save_attachment_bytes(data, suffix)
875
+ except OSError:
876
+ self._apply_attachments(hint=_t(self._language, "discussion_drop_failed"))
877
+ return
878
+ self._add_attachment(target)
879
+ self._apply_attachments()
880
+
881
+ def _handle_drop_image(self, data: str, name: str) -> None:
882
+ suffix = Path(name).suffix.lower()
883
+ if suffix not in ATTACHMENT_SUFFIXES:
884
+ self._apply_attachments(
885
+ hint=_t(self._language, "discussion_drop_not_image")
886
+ )
887
+ return
888
+ try:
889
+ raw = base64.b64decode(data, validate=True)
890
+ except ValueError:
891
+ self._apply_attachments(hint=_t(self._language, "discussion_drop_failed"))
892
+ return
893
+ if len(raw) > DROP_MAX_BYTES:
894
+ self._apply_attachments(
895
+ hint=_t(self._language, "discussion_drop_too_large")
896
+ )
897
+ return
898
+ try:
899
+ target = save_attachment_bytes(raw, suffix)
900
+ except OSError:
901
+ self._apply_attachments(hint=_t(self._language, "discussion_drop_failed"))
902
+ return
903
+ self._add_attachment(target)
904
+ self._apply_attachments()
905
+
906
+ def _handle_pick_image(self) -> None:
907
+ selected = pick_image_file()
908
+ if selected is None:
909
+ return
910
+ try:
911
+ target = import_attachment_file(selected)
912
+ except OSError:
913
+ self._apply_attachments(hint=_t(self._language, "discussion_drop_failed"))
914
+ return
915
+ if target is None:
916
+ self._apply_attachments(
917
+ hint=_t(self._language, "discussion_paste_no_image")
918
+ )
919
+ return
920
+ self._add_attachment(target)
921
+ self._apply_attachments()
922
+
923
+ def _add_attachment(self, target: Path) -> None:
924
+ attachment = {"name": target.name, "path": str(target)}
925
+ thumbnail = attachment_thumbnail_data_uri(target)
926
+ if thumbnail is not None:
927
+ attachment["thumbnail"] = thumbnail
928
+ self._attachments.append(attachment)
929
+
930
+ def _remove_attachment(self, path: str) -> None:
931
+ before = len(self._attachments)
932
+ self._attachments = [
933
+ attachment for attachment in self._attachments if attachment["path"] != path
934
+ ]
935
+ if len(self._attachments) == before:
936
+ return
937
+ with contextlib.suppress(OSError):
938
+ Path(path).unlink()
939
+ self._apply_attachments()
940
+
941
+ def _evaluate(self, function_name: str, payload: object) -> None:
942
+ self._require_main_thread()
943
+ if not self._attached or not self._web_ready or self.webview is None:
944
+ return
945
+ self.webview.evaluateJavaScript_completionHandler_(
946
+ serialize_javascript_call(function_name, payload),
947
+ None,
948
+ )
949
+
950
+ @staticmethod
951
+ def _require_main_thread() -> None:
952
+ if sys.platform != "darwin":
953
+ raise RuntimeError("discussion window is available only on macOS")
954
+ if not NSThread.isMainThread():
955
+ raise RuntimeError("discussion window operations require the main thread")