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.
- adapters/__init__.py +5 -0
- adapters/agy.py +68 -0
- adapters/claude.py +215 -0
- adapters/codex.py +209 -0
- adapters/rate_limits.py +76 -0
- adapters/registry.py +17 -0
- adapters/types.py +139 -0
- agy_disk_cache.py +135 -0
- agy_loader.py +416 -0
- agy_quota_probe.py +748 -0
- agy_window_keeper.py +185 -0
- analyzer/__init__.py +5 -0
- analyzer/aggregator.py +139 -0
- analyzer/blocks.py +80 -0
- analyzer/diagnoser.py +638 -0
- analyzer/insights.py +277 -0
- analyzer/persona_loader.py +199 -0
- analyzer/reporter.py +989 -0
- analyzer/subscription.py +108 -0
- burn_rate.py +75 -0
- cache_quarantine.py +50 -0
- codex_disk_cache.py +227 -0
- codex_events.py +136 -0
- codex_fork_replay.py +111 -0
- codex_loader.py +1426 -0
- codex_paths.py +20 -0
- critter_frames.py +26 -0
- discussion_bridge.py +1196 -0
- discussion_cli.py +844 -0
- discussion_session.py +622 -0
- discussion_usage.py +13 -0
- discussion_window.py +955 -0
- disk_cache_common.py +132 -0
- disk_cache_lifecycle.py +39 -0
- doctor.py +452 -0
- fsevents_watch.py +207 -0
- history_disk_cache.py +110 -0
- history_loader.py +416 -0
- i18n.py +88 -0
- jsonl_limits.py +17 -0
- jsonl_utils.py +40 -0
- login_item.py +154 -0
- main.py +387 -0
- menubar.py +1201 -0
- menubar_actions.py +204 -0
- menubar_agy.py +193 -0
- menubar_chrome.py +156 -0
- menubar_menu.py +169 -0
- menubar_notify.py +102 -0
- menubar_popover.py +233 -0
- menubar_prefs.py +118 -0
- menubar_refresh.py +285 -0
- menubar_state.py +1200 -0
- menubar_title.py +157 -0
- menubar_update.py +123 -0
- panel_window.py +78 -0
- panel_window_state.py +159 -0
- panels/__init__.py +186 -0
- panels/base.py +83 -0
- panels/dynamic_height.py +140 -0
- panels/payload.py +178 -0
- panels/web_panel.py +513 -0
- panels/window_drag.py +56 -0
- prefs.py +44 -0
- pricing.py +452 -0
- project_resolver.py +112 -0
- service_status.py +383 -0
- session_hooks.py +1154 -0
- setup_app.py +171 -0
- setup_hook.py +1011 -0
- statusline_settings.py +160 -0
- talent_market_bridge.py +243 -0
- time_utils.py +24 -0
- tui.py +288 -0
- tui_sprite.py +206 -0
- ui/__init__.py +5 -0
- ui/html_report.py +923 -0
- ui/report_scripts.py +251 -0
- ui/report_styles.py +370 -0
- ui/tables.py +888 -0
- update_checker.py +156 -0
- update_gate.py +66 -0
- update_release_notes.py +49 -0
- usage_cli-0.29.32.data/data/share/usage/i18n.json +2427 -0
- usage_cli-0.29.32.dist-info/METADATA +223 -0
- usage_cli-0.29.32.dist-info/RECORD +109 -0
- usage_cli-0.29.32.dist-info/WHEEL +5 -0
- usage_cli-0.29.32.dist-info/entry_points.txt +3 -0
- usage_cli-0.29.32.dist-info/licenses/LICENSE +663 -0
- usage_cli-0.29.32.dist-info/top_level.txt +80 -0
- usage_cli.py +827 -0
- usage_client.py +487 -0
- usage_diagnosis_snapshot.py +143 -0
- usage_dir_sweeper.py +100 -0
- usage_lang.py +79 -0
- usage_logging.py +75 -0
- usage_notifications.py +96 -0
- usage_rate.py +97 -0
- usage_session_resume.py +913 -0
- usage_statusline.py +810 -0
- usage_statusline_agy.py +397 -0
- usage_statusline_forwarder.py +88 -0
- usage_terse_mode.py +223 -0
- usage_terse_reminder.py +151 -0
- win_login_item.py +53 -0
- window_keeper.py +264 -0
- windows_watch.py +443 -0
- wintray.py +2014 -0
- 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")
|