echoact 0.1.0__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 (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,494 @@
1
+ """Handing the user what their MCP client needs, per F-58.
2
+
3
+ F-58 says client registration information, including the credential, is
4
+ provided to the user by the app. Until now it was not: the user had to
5
+ know that the executable takes ``--mcp``, that it reads ``ECHOACT_URL``
6
+ and ``ECHOACT_TOKEN``, and what shape their particular client wants that
7
+ in. None of that is discoverable from anywhere.
8
+
9
+ One tab per client, because the differences are not cosmetic -- the key
10
+ is ``mcpServers`` in one, ``servers`` in another, and a TOML table in a
11
+ third -- and a single "here are the values, work it out" panel is what
12
+ the user already has.
13
+
14
+ Two things this screen is careful about:
15
+
16
+ * **The credential can only be shown once.** F-71 keeps a verifier and
17
+ nothing else, so a snippet for an existing credential cannot contain
18
+ the real value and does not pretend to: it carries a placeholder, and
19
+ issuing a new one from here is offered instead. A snippet that looks
20
+ complete but holds a token EchoAct cannot actually produce would be
21
+ worse than one that says what is missing.
22
+ * **These are not verified clients.** F-62 requires tool discovery,
23
+ errors, progress, cancellation and result retrieval to be exercised on
24
+ the actual target clients, and Section 8.1 says compatibility with
25
+ clients not listed is not guaranteed. None of that has happened, so
26
+ the screen says the snippets follow each client's documented format
27
+ rather than implying they have been tried.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import sys
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+
37
+ from PySide6.QtCore import Qt, Signal
38
+ from PySide6.QtWidgets import (
39
+ QApplication,
40
+ QComboBox,
41
+ QFrame,
42
+ QHBoxLayout,
43
+ QLabel,
44
+ QPlainTextEdit,
45
+ QPushButton,
46
+ QScrollArea,
47
+ QTabWidget,
48
+ QVBoxLayout,
49
+ QWidget,
50
+ )
51
+
52
+ from ..policy import MCP_PROTOCOL_REVISION, REST_HOST
53
+ from ..security.credentials import Credential
54
+ from . import icons
55
+ from .i18n import add_korean, tr
56
+ from .theme import METRICS, Palette
57
+
58
+ add_korean(
59
+ {
60
+ "Connect an app": "앱 연결",
61
+ "Set up an MCP client": "MCP 클라이언트 설정",
62
+ "MCP is off. Turn it on in Settings before an app can connect.":
63
+ "MCP가 꺼져 있습니다. 앱이 연결하려면 설정에서 먼저 켜세요.",
64
+ "The local service is off, so MCP is unavailable too.":
65
+ "로컬 서비스가 꺼져 있어 MCP도 사용할 수 없습니다.",
66
+ "MCP is on. EchoAct answers on {host}:{port}.":
67
+ "MCP가 켜져 있습니다. 에코액트는 {host}:{port}에서 응답합니다.",
68
+ "Open Settings": "설정 열기",
69
+ "Credential": "자격 증명",
70
+ "Issue a new one": "새로 발급",
71
+ "EchoAct keeps only a verifier, so an existing credential cannot be shown again. "
72
+ "Issue one for this app and the snippet below will contain it.":
73
+ "에코액트는 검증 값만 저장하므로 기존 자격 증명은 다시 표시할 수 없습니다. "
74
+ "이 앱용으로 새로 발급하면 아래 설정에 값이 채워집니다.",
75
+ "This credential is in the snippet below and will not be shown again.":
76
+ "이 자격 증명은 아래 설정에 포함되어 있으며 다시 표시되지 않습니다.",
77
+ "Put this in": "넣을 위치",
78
+ "Copy": "복사",
79
+ "Copied": "복사됨",
80
+ "Copy the path": "경로 복사",
81
+ "Restart the app after saving the file.": "파일을 저장한 뒤 앱을 다시 시작하세요.",
82
+ "These follow each client's documented format. None has been verified "
83
+ "against the client itself yet, so treat a failure as a setup question "
84
+ "rather than a fault in the app.":
85
+ "각 클라이언트의 공개된 형식을 따릅니다. 아직 실제 클라이언트에서 검증하지 않았으므로, "
86
+ "연결되지 않으면 앱의 결함이 아니라 설정 문제로 보고 확인하세요.",
87
+ "Protocol revision {revision}. Eight tools, stdio only.":
88
+ "프로토콜 개정판 {revision}. 도구 8개, stdio 전용.",
89
+ "No credential yet": "아직 자격 증명 없음",
90
+ }
91
+ )
92
+
93
+ #: What the user pastes when they have not issued a credential here. A
94
+ #: value that looks obviously unfilled, rather than a plausible-looking
95
+ #: fake that might be copied as-is.
96
+ TOKEN_PLACEHOLDER = "PASTE-THE-CREDENTIAL-ECHOACT-SHOWED-YOU"
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class Launch:
101
+ """How this installation starts its own MCP server.
102
+
103
+ A checkout and a packaged build differ: a bundle's ``sys.executable``
104
+ is the application and ``-m`` means nothing to it, so the executable
105
+ re-invokes itself. Getting this wrong is the most likely reason a
106
+ client fails to start the server, which is why the screen computes it
107
+ rather than printing an example.
108
+ """
109
+
110
+ command: str
111
+ args: tuple[str, ...]
112
+ env: dict[str, str]
113
+
114
+ @property
115
+ def shell(self) -> str:
116
+ parts = [_quote(self.command), *(_quote(a) for a in self.args)]
117
+ return " ".join(parts)
118
+
119
+
120
+ def _quote(value: str) -> str:
121
+ return f'"{value}"' if " " in value else value
122
+
123
+
124
+ def _portable(path: str) -> str:
125
+ """A Windows path written with forward slashes.
126
+
127
+ Windows accepts them wherever a path is opened or executed, and
128
+ they avoid two problems at once. JSON would otherwise need every
129
+ separator doubled, which is noise in something the user is meant
130
+ to read; and a Korean-locale font draws U+005C as the won sign, so
131
+ a path full of backslashes appears on screen as a path full of
132
+ currency symbols and reads as a mistake in a snippet the user is
133
+ about to trust.
134
+ """
135
+ return path.replace(chr(92), "/")
136
+
137
+
138
+ def launch_for_this_install(port: int) -> Launch:
139
+ command = _portable(sys.executable)
140
+ if getattr(sys, "frozen", False):
141
+ args: tuple[str, ...] = ("--mcp",)
142
+ env = {"ECHOACT_URL": f"http://{REST_HOST}:{port}"}
143
+ else:
144
+ args = ("-m", "echoact.mcp")
145
+ # From a checkout the package is not installed where an arbitrary
146
+ # working directory can find it, and an MCP client starts the
147
+ # process from its own.
148
+ env = {
149
+ "ECHOACT_URL": f"http://{REST_HOST}:{port}",
150
+ "PYTHONPATH": _portable(str(Path(__file__).resolve().parents[2])),
151
+ }
152
+ return Launch(command=command, args=args, env=env)
153
+
154
+
155
+ # ======================================================================
156
+ # The snippets
157
+ # ======================================================================
158
+
159
+
160
+ def _json_server(launch: Launch, token: str, *, key: str, typed: bool) -> str:
161
+ entry: dict[str, object] = {}
162
+ if typed:
163
+ # VS Code names the transport explicitly; the others infer stdio.
164
+ entry["type"] = "stdio"
165
+ entry["command"] = launch.command
166
+ entry["args"] = list(launch.args)
167
+ entry["env"] = {**launch.env, "ECHOACT_TOKEN": token}
168
+ return json.dumps({key: {"echoact": entry}}, indent=2, ensure_ascii=False)
169
+
170
+
171
+ def _toml_server(launch: Launch, token: str) -> str:
172
+ """The environment as its own table rather than an inline one.
173
+
174
+ Both are valid TOML; the inline form puts three long values on a
175
+ single line, which runs off the side of any window the user reads
176
+ it in and makes the token the part that gets cut off.
177
+ """
178
+ env = {**launch.env, "ECHOACT_TOKEN": token}
179
+ args = ", ".join(json.dumps(a) for a in launch.args)
180
+ lines = [
181
+ "[mcp_servers.echoact]",
182
+ f"command = {json.dumps(launch.command)}",
183
+ f"args = [{args}]",
184
+ "",
185
+ "[mcp_servers.echoact.env]",
186
+ *(f"{k} = {json.dumps(v)}" for k, v in env.items()),
187
+ ]
188
+ return "\n".join(lines) + "\n"
189
+
190
+
191
+ def _claude_code_cli(launch: Launch, token: str) -> str:
192
+ env = {**launch.env, "ECHOACT_TOKEN": token}
193
+ flags = " ".join(f"--env {k}={_quote(v)}" for k, v in env.items())
194
+ return f"claude mcp add echoact --scope user {flags} -- {launch.shell}"
195
+
196
+
197
+ @dataclass(frozen=True, slots=True)
198
+ class ClientGuide:
199
+ key: str
200
+ title: str
201
+ #: Where the file lives, per operating system.
202
+ location: str
203
+ body: str
204
+ note: str = ""
205
+
206
+
207
+ def guides(launch: Launch, token: str) -> tuple[ClientGuide, ...]:
208
+ """One entry per client, in the order a user is likely to want them."""
209
+ windows = sys.platform == "win32"
210
+ return (
211
+ ClientGuide(
212
+ key="claude-desktop",
213
+ title="Claude Desktop",
214
+ location=(
215
+ r"%APPDATA%\Claude\claude_desktop_config.json"
216
+ if windows
217
+ else "~/Library/Application Support/Claude/claude_desktop_config.json"
218
+ ),
219
+ body=_json_server(launch, token, key="mcpServers", typed=False),
220
+ note=tr("Restart the app after saving the file."),
221
+ ),
222
+ ClientGuide(
223
+ key="claude-code",
224
+ title="Claude Code",
225
+ location=tr("Run this in a terminal"),
226
+ body=_claude_code_cli(launch, token),
227
+ note="Or put the Claude Desktop block above in .mcp.json at the root of a project.",
228
+ ),
229
+ ClientGuide(
230
+ key="codex",
231
+ title="Codex CLI",
232
+ location=r"%USERPROFILE%\.codex\config.toml" if windows else "~/.codex/config.toml",
233
+ body=_toml_server(launch, token),
234
+ ),
235
+ ClientGuide(
236
+ key="vscode",
237
+ title="VS Code",
238
+ location=".vscode/mcp.json in a workspace, or the user-level mcp.json",
239
+ body=_json_server(launch, token, key="servers", typed=True),
240
+ ),
241
+ ClientGuide(
242
+ key="cursor",
243
+ title="Cursor",
244
+ location=r"%USERPROFILE%\.cursor\mcp.json" if windows else "~/.cursor/mcp.json",
245
+ body=_json_server(launch, token, key="mcpServers", typed=False),
246
+ ),
247
+ ClientGuide(
248
+ key="other",
249
+ title=tr("Anything else"),
250
+ location=tr("The values, for a client that is not listed"),
251
+ body=(
252
+ f"transport stdio\n"
253
+ f"command {launch.command}\n"
254
+ f"args {' '.join(launch.args)}\n"
255
+ + "".join(
256
+ f"env {k}={v}\n"
257
+ for k, v in {**launch.env, "ECHOACT_TOKEN": token}.items()
258
+ )
259
+ ),
260
+ ),
261
+ )
262
+
263
+
264
+ # ======================================================================
265
+ # The screen
266
+ # ======================================================================
267
+
268
+
269
+ class _Snippet(QFrame):
270
+ """A block of configuration with somewhere to put it and a Copy."""
271
+
272
+ def __init__(self, guide: ClientGuide, palette: Palette, parent: QWidget | None = None):
273
+ super().__init__(parent)
274
+ self.setObjectName("Panel")
275
+ m = METRICS
276
+ box = QVBoxLayout(self)
277
+ box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
278
+ box.setSpacing(m.gap)
279
+
280
+ where = QHBoxLayout()
281
+ where.setSpacing(m.gap)
282
+ label = QLabel(tr("Put this in"))
283
+ label.setProperty("role", "secondary")
284
+ where.addWidget(label)
285
+ self.location = QLabel(guide.location)
286
+ self.location.setProperty("role", "section")
287
+ # A configuration path is the one place a backslash has to look
288
+ # like a backslash; the interface face draws it as the won sign.
289
+ self.location.setProperty("mono", True)
290
+ self.location.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
291
+ self.location.setWordWrap(True)
292
+ where.addWidget(self.location, 1)
293
+ self.copy_path = QPushButton(tr("Copy the path"))
294
+ self.copy_path.setProperty("variant", "quiet")
295
+ self.copy_path.clicked.connect(lambda: _to_clipboard(guide.location))
296
+ where.addWidget(self.copy_path)
297
+ box.addLayout(where)
298
+
299
+ self.body = QPlainTextEdit(guide.body)
300
+ self.body.setReadOnly(True)
301
+ self.body.setProperty("mono", True)
302
+ self.body.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
303
+ self.body.setAccessibleName(f"{guide.title} configuration")
304
+ self.body.setMinimumHeight(190)
305
+ box.addWidget(self.body, 1)
306
+
307
+ row = QHBoxLayout()
308
+ if guide.note:
309
+ note = QLabel(guide.note)
310
+ note.setProperty("role", "muted")
311
+ note.setWordWrap(True)
312
+ row.addWidget(note, 1)
313
+ else:
314
+ row.addStretch(1)
315
+ self.copy = QPushButton(" " + tr("Copy"))
316
+ self.copy.setProperty("variant", "primary")
317
+ self.copy.setIcon(icons.icon("copy", palette.text_on_accent))
318
+ self.copy.setIconSize(icons.icon_size(16))
319
+ self.copy.clicked.connect(self._copy)
320
+ row.addWidget(self.copy)
321
+ box.addLayout(row)
322
+
323
+ def _copy(self) -> None:
324
+ _to_clipboard(self.body.toPlainText())
325
+ self.copy.setText(" " + tr("Copied"))
326
+
327
+
328
+ def _to_clipboard(value: str) -> None:
329
+ clipboard = QApplication.clipboard()
330
+ if clipboard is not None:
331
+ clipboard.setText(value)
332
+
333
+
334
+ class McpSetupView(QWidget):
335
+ """F-58's registration information, one tab per client."""
336
+
337
+ #: (name) -- the window issues the credential and calls ``set_token``.
338
+ credential_requested = Signal(str)
339
+ open_settings_requested = Signal()
340
+
341
+ def __init__(
342
+ self,
343
+ palette: Palette,
344
+ *,
345
+ port: int,
346
+ mcp_enabled: bool,
347
+ rest_enabled: bool,
348
+ credentials: list[Credential] | None = None,
349
+ parent: QWidget | None = None,
350
+ ) -> None:
351
+ super().__init__(parent)
352
+ self.setObjectName("Root")
353
+ self._palette = palette
354
+ self._launch = launch_for_this_install(port)
355
+ self._token = TOKEN_PLACEHOLDER
356
+
357
+ m = METRICS
358
+ outer = QVBoxLayout(self)
359
+ outer.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
360
+ outer.setSpacing(m.gap_wide)
361
+
362
+ title = QLabel(tr("Set up an MCP client"))
363
+ title.setProperty("role", "title")
364
+ outer.addWidget(title)
365
+
366
+ self.state = QLabel()
367
+ self.state.setWordWrap(True)
368
+ outer.addWidget(self.state)
369
+ self.settings_button = QPushButton(tr("Open Settings"))
370
+ self.settings_button.setProperty("variant", "quiet")
371
+ self.settings_button.clicked.connect(self.open_settings_requested)
372
+ outer.addWidget(self.settings_button, 0, Qt.AlignmentFlag.AlignLeft)
373
+ self.set_service_state(mcp_enabled=mcp_enabled, rest_enabled=rest_enabled, port=port)
374
+
375
+ outer.addWidget(self._credential_row(credentials or []))
376
+
377
+ self.tabs = QTabWidget()
378
+ self.tabs.setDocumentMode(True)
379
+ outer.addWidget(self.tabs, 1)
380
+ self._rebuild_tabs()
381
+
382
+ footer = QLabel(
383
+ tr(
384
+ "These follow each client's documented format. None has been verified "
385
+ "against the client itself yet, so treat a failure as a setup question "
386
+ "rather than a fault in the app."
387
+ )
388
+ )
389
+ footer.setProperty("role", "muted")
390
+ footer.setWordWrap(True)
391
+ outer.addWidget(footer)
392
+
393
+ revision = QLabel(
394
+ tr("Protocol revision {revision}. Eight tools, stdio only.").format(
395
+ revision=MCP_PROTOCOL_REVISION
396
+ )
397
+ )
398
+ revision.setProperty("role", "muted")
399
+ outer.addWidget(revision)
400
+
401
+ # -- credential ----------------------------------------------------
402
+
403
+ def _credential_row(self, credentials: list[Credential]) -> QWidget:
404
+ m = METRICS
405
+ panel = QFrame()
406
+ panel.setObjectName("Panel")
407
+ box = QVBoxLayout(panel)
408
+ box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
409
+ box.setSpacing(m.gap)
410
+
411
+ row = QHBoxLayout()
412
+ row.setSpacing(m.gap)
413
+ label = QLabel(tr("Credential"))
414
+ label.setProperty("role", "section")
415
+ row.addWidget(label)
416
+ self.clients = QComboBox()
417
+ self.clients.setEditable(True)
418
+ self.clients.setAccessibleName(tr("Credential"))
419
+ self.clients.addItems([c.name for c in credentials] or [])
420
+ if not credentials:
421
+ self.clients.setEditText("My MCP client")
422
+ row.addWidget(self.clients, 1)
423
+ self.issue = QPushButton(tr("Issue a new one"))
424
+ self.issue.setProperty("variant", "primary")
425
+ self.issue.clicked.connect(
426
+ lambda: self.credential_requested.emit(self.clients.currentText().strip() or "MCP client")
427
+ )
428
+ row.addWidget(self.issue)
429
+ box.addLayout(row)
430
+
431
+ self.credential_note = QLabel(
432
+ tr(
433
+ "EchoAct keeps only a verifier, so an existing credential cannot be shown "
434
+ "again. Issue one for this app and the snippet below will contain it."
435
+ )
436
+ )
437
+ self.credential_note.setProperty("role", "muted")
438
+ self.credential_note.setWordWrap(True)
439
+ box.addWidget(self.credential_note)
440
+ return panel
441
+
442
+ def set_token(self, token: str) -> None:
443
+ """Put a freshly issued credential into every snippet.
444
+
445
+ The only moment a real value can appear here: F-71 shows it once
446
+ and keeps a verifier, so this screen cannot fetch it later and
447
+ does not try.
448
+ """
449
+ self._token = token
450
+ self.credential_note.setText(
451
+ tr("This credential is in the snippet below and will not be shown again.")
452
+ )
453
+ self.credential_note.setProperty("role", "warn")
454
+ self.credential_note.style().unpolish(self.credential_note)
455
+ self.credential_note.style().polish(self.credential_note)
456
+ self._rebuild_tabs()
457
+
458
+ @property
459
+ def token(self) -> str:
460
+ return self._token
461
+
462
+ def set_service_state(self, *, mcp_enabled: bool, rest_enabled: bool, port: int) -> None:
463
+ """F-46: turning REST off makes MCP unavailable, and the
464
+ consequence is stated where the change is felt."""
465
+ if not rest_enabled:
466
+ text = tr("The local service is off, so MCP is unavailable too.")
467
+ role = "warn"
468
+ elif not mcp_enabled:
469
+ text = tr("MCP is off. Turn it on in Settings before an app can connect.")
470
+ role = "warn"
471
+ else:
472
+ text = tr("MCP is on. EchoAct answers on {host}:{port}.").format(
473
+ host=REST_HOST, port=port
474
+ )
475
+ role = "ok"
476
+ self.state.setText(text)
477
+ self.state.setProperty("role", role)
478
+ self.state.style().unpolish(self.state)
479
+ self.state.style().polish(self.state)
480
+ self.settings_button.setVisible(role == "warn")
481
+
482
+ # -- tabs ----------------------------------------------------------
483
+
484
+ def _rebuild_tabs(self) -> None:
485
+ current = self.tabs.currentIndex()
486
+ self.tabs.clear()
487
+ for guide in guides(self._launch, self._token):
488
+ page = QScrollArea()
489
+ page.setWidgetResizable(True)
490
+ page.setFrameShape(QScrollArea.Shape.NoFrame)
491
+ page.setWidget(_Snippet(guide, self._palette))
492
+ self.tabs.addTab(page, guide.title)
493
+ if 0 <= current < self.tabs.count():
494
+ self.tabs.setCurrentIndex(current)