synapse-cli-agent 0.1.13__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 (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,407 @@
1
+ """MCP server & tool selection panel — invoked by /mcp or F5.
2
+
3
+ Shows servers and their discovered tools with checkboxes. Select which tools
4
+ to enable per server, then save to the config file so only those tools are
5
+ loaded on next startup / reload.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Callable
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from textual.app import ComposeResult
16
+ from textual.binding import Binding
17
+
18
+ from synapse.ui.dialogs.base import DialogBase, OptionItem
19
+
20
+ # Wider dialog with visible scrollbar for potentially long tool lists.
21
+ MCP_DIALOG_CSS = """
22
+ McpPanelDialog {
23
+ align: center middle;
24
+ background: $theme-bg 60%;
25
+ }
26
+ McpPanelDialog > #dialog-window {
27
+ width: 72;
28
+ height: auto;
29
+ max-height: 38;
30
+ background: $theme-bg;
31
+ border: round $theme-user;
32
+ border-title-color: $theme-fg;
33
+ border-title-background: $theme-top;
34
+ border-title-style: bold;
35
+ border-title-align: left;
36
+ border-subtitle-color: $theme-muted;
37
+ border-subtitle-align: right;
38
+ padding: 0;
39
+ layout: vertical;
40
+ }
41
+ McpPanelDialog #dialog-body {
42
+ height: auto;
43
+ max-height: 32;
44
+ min-height: 3;
45
+ width: 1fr;
46
+ padding: 0 1;
47
+ background: $theme-bg;
48
+ overflow-y: auto;
49
+ overflow-x: hidden;
50
+ scrollbar-size: 1 2;
51
+ scrollbar-background: $theme-bar;
52
+ scrollbar-color: $theme-dim;
53
+ scrollbar-background-hover: $theme-bar;
54
+ scrollbar-color-hover: $theme-user;
55
+ scrollbar-background-active: $theme-bar;
56
+ scrollbar-color-active: $theme-user;
57
+ }
58
+ McpPanelDialog DialogBody OptionRow {
59
+ height: 1;
60
+ width: 1fr;
61
+ color: $theme-dim;
62
+ padding: 0 1;
63
+ background: $theme-bg;
64
+ overflow: hidden;
65
+ text-overflow: ellipsis;
66
+ }
67
+ McpPanelDialog DialogBody OptionRow.-selected {
68
+ color: $theme-user;
69
+ background: $theme-bar;
70
+ text-style: bold;
71
+ }
72
+ McpPanelDialog DialogBody SectionHeader {
73
+ height: 1;
74
+ width: 1fr;
75
+ color: $theme-orange;
76
+ padding: 0 1;
77
+ text-style: bold;
78
+ }
79
+ """
80
+
81
+
82
+ def _resolve_mcp_config_path(settings: Any, project_root: Path | None = None) -> Path | None:
83
+ """Find the mcp.json file to write to (prefer explicit → project → user)."""
84
+ explicit: str | None = getattr(settings, "mcp_config_path", None)
85
+ if explicit:
86
+ p = Path(explicit).expanduser()
87
+ if p.is_file():
88
+ return p
89
+ return p
90
+
91
+ from synapse.settings.config_paths import mcp_config_paths
92
+
93
+ existing = mcp_config_paths(project_root)
94
+ if existing:
95
+ return existing[-1]
96
+
97
+ from synapse.settings.config_paths import project_config_dir
98
+
99
+ return project_config_dir(project_root) / "mcp.json"
100
+
101
+
102
+ def _save_include_tools_to_config(
103
+ settings: Any,
104
+ server_name: str,
105
+ include_tools: list[str] | None,
106
+ project_root: Path | None = None,
107
+ ) -> Path | None:
108
+ """Write ``include_tools`` for one server into the mcp.json config file.
109
+
110
+ Returns the path written, or None on failure.
111
+ """
112
+ config_path = _resolve_mcp_config_path(settings, project_root)
113
+ if config_path is None:
114
+ return None
115
+
116
+ try:
117
+ if config_path.is_file():
118
+ data = json.loads(config_path.read_text(encoding="utf-8"))
119
+ else:
120
+ data = {}
121
+ except (json.JSONDecodeError, OSError):
122
+ data = {}
123
+
124
+ if not isinstance(data, dict):
125
+ data = {}
126
+
127
+ servers: list[dict[str, Any]] = []
128
+ if isinstance(data.get("servers"), list):
129
+ servers = data["servers"]
130
+ elif isinstance(data, list):
131
+ servers = data
132
+
133
+ for s in servers:
134
+ if isinstance(s, dict) and s.get("name") == server_name:
135
+ if include_tools is None or len(include_tools) == 0:
136
+ s.pop("include_tools", None)
137
+ else:
138
+ s["include_tools"] = include_tools
139
+ break
140
+ else:
141
+ return None
142
+
143
+ config_path.parent.mkdir(parents=True, exist_ok=True)
144
+
145
+ if isinstance(data.get("servers"), list):
146
+ data["servers"] = servers
147
+ config_path.write_text(
148
+ json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
149
+ )
150
+ elif isinstance(data, list):
151
+ config_path.write_text(
152
+ json.dumps(servers, indent=2, ensure_ascii=False), encoding="utf-8"
153
+ )
154
+
155
+ return config_path
156
+
157
+
158
+ class McpPanelDialog(DialogBase):
159
+ """List MCP servers and their tools; toggle per-tool; save to config."""
160
+
161
+ DEFAULT_CSS = MCP_DIALOG_CSS
162
+ BINDINGS = [
163
+ *DialogBase.BINDINGS,
164
+ Binding("space", "toggle_check", "Toggle", show=False, priority=True),
165
+ Binding("s", "save", "Save", show=False, priority=True),
166
+ Binding("r", "reload", "Reload", show=False, priority=True),
167
+ Binding("d", "toggle_server", "Toggle server", show=False, priority=True),
168
+ ]
169
+ _title_icon = ""
170
+ _title_keys = (
171
+ "\u2191\u2193 move \u00b7 space/\u21b5 fold/toggle \u00b7 d toggle server \u00b7"
172
+ " ctrl+a all \u00b7 s save \u00b7 r reload \u00b7 esc close"
173
+ )
174
+
175
+ def __init__(
176
+ self,
177
+ settings: Any,
178
+ *,
179
+ project_root: Any = None,
180
+ on_save: Callable[[], None] | None = None,
181
+ ) -> None:
182
+ super().__init__()
183
+ self._settings = settings
184
+ self._project_root = project_root
185
+ self._on_save = on_save
186
+
187
+ try:
188
+ from synapse.integrations.mcp_client import get_active_mcp_pool, load_mcp_server_configs
189
+
190
+ self._servers = load_mcp_server_configs(
191
+ path=getattr(settings, "mcp_config_path", None),
192
+ json_blob=getattr(settings, "mcp_servers_json", None),
193
+ workspace=getattr(settings, "workspace", None),
194
+ )
195
+ except Exception: # noqa: BLE001
196
+ self._servers = []
197
+
198
+ pool = None
199
+ try:
200
+ pool = get_active_mcp_pool()
201
+ except Exception: # noqa: BLE001
202
+ pass
203
+
204
+ # server_name → (all_discovered_tools, currently_included)
205
+ self._server_tools: dict[str, tuple[list[str], set[str]]] = {}
206
+ # server_name → collapsed flag
207
+ self._collapsed: dict[str, bool] = {}
208
+ for srv in self._servers:
209
+ discovered: list[str] = []
210
+ if pool is not None:
211
+ discovered = list(
212
+ getattr(pool, "discovered_tools", {}).get(srv.name, [])
213
+ )
214
+ if srv.include_tools is not None:
215
+ included = set(srv.include_tools)
216
+ else:
217
+ included = set(discovered) if discovered else set()
218
+ self._server_tools[srv.name] = (discovered, included)
219
+
220
+ @property
221
+ def title_text(self) -> str:
222
+ return "MCP Tools"
223
+
224
+ def compose_body(self) -> ComposeResult:
225
+ items: list[OptionItem] = self._build_item_list()
226
+ self._items = items
227
+ # Options are mounted in on_mount via set_options.
228
+ yield from ()
229
+
230
+ def _build_item_list(self) -> list[OptionItem]:
231
+ items: list[OptionItem] = []
232
+ if not self._servers:
233
+ items.append(
234
+ OptionItem(
235
+ key="none",
236
+ label="(no servers configured)",
237
+ checkable=False,
238
+ show_bullet=False,
239
+ )
240
+ )
241
+ return items
242
+
243
+ for srv in self._servers:
244
+ discovered, included = self._server_tools.get(srv.name, ([], set()))
245
+ collapsed = self._collapsed.get(srv.name, False)
246
+ arrow = "\u25b6" if collapsed else "\u25bc" # ▶ / ▼
247
+ status = "enabled" if srv.enabled else "disabled"
248
+ if srv.enabled and discovered:
249
+ n_sel = len(included)
250
+ n_tot = len(discovered)
251
+ sel_info = f"{n_sel}/{n_tot} selected" if n_sel < n_tot else "all selected"
252
+ meta = f"{sel_info} \u00b7 {srv.transport} \u00b7 {status}"
253
+ else:
254
+ meta = f"{srv.transport} \u00b7 {status}"
255
+ items.append(
256
+ OptionItem(
257
+ key=f"__srv__{srv.name}",
258
+ label=f"{arrow} Server: {srv.name}",
259
+ meta=meta,
260
+ checkable=False,
261
+ show_bullet=False,
262
+ )
263
+ )
264
+ if not srv.enabled:
265
+ continue
266
+ if not discovered:
267
+ items.append(
268
+ OptionItem(
269
+ key=f"__hint__{srv.name}",
270
+ label="(no tools \u2014 press 'r' to connect)",
271
+ checkable=False,
272
+ show_bullet=False,
273
+ indent=" ",
274
+ )
275
+ )
276
+ continue
277
+ if collapsed:
278
+ continue # skip tool rows when collapsed
279
+ for tool_name in discovered:
280
+ checked = tool_name in included
281
+ items.append(
282
+ OptionItem(
283
+ key=f"__tool__{srv.name}__{tool_name}",
284
+ label=tool_name,
285
+ checkable=True,
286
+ checked=checked,
287
+ indent=" ",
288
+ )
289
+ )
290
+ return items
291
+
292
+ def on_mount(self) -> None:
293
+ super().on_mount()
294
+ body = self.query_one("#dialog-body")
295
+ body.set_options(self._items, mark="", checkable=True)
296
+
297
+ def action_reload(self) -> None:
298
+ self.dismiss(("mcp-reload",))
299
+
300
+ def action_toggle_server(self) -> None:
301
+ """Temporarily toggle the selected MCP server and reload."""
302
+ body = self.query_one("#dialog-body")
303
+ key = body.selected_key
304
+ if key is None or not key.startswith("__srv__"):
305
+ return
306
+ server_name = key.split("__", 3)[2]
307
+ if not any(srv.name == server_name for srv in self._servers):
308
+ return
309
+ self.dismiss(("mcp-toggle-server", server_name))
310
+
311
+ def action_save(self) -> None:
312
+ """Collect tool selections and dismiss — save + reload runs off the UI thread."""
313
+ # Build per-server include_tools to write to config.
314
+ to_save: dict[str, list[str] | None] = {}
315
+ for srv in self._servers:
316
+ if not srv.enabled:
317
+ continue
318
+ discovered, included = self._server_tools.get(srv.name, ([], set()))
319
+ if not discovered:
320
+ continue
321
+ if included == set(discovered):
322
+ # All selected → remove include_tools (loads all)
323
+ to_save[srv.name] = None
324
+ else:
325
+ to_save[srv.name] = sorted(included)
326
+
327
+ if not to_save:
328
+ self.dismiss(("mcp-reload",))
329
+ return
330
+
331
+ self.dismiss(("mcp-save", to_save))
332
+
333
+ def _toggle_fold(self, server_name: str) -> None:
334
+ """Toggle collapse state for a server group."""
335
+ self._collapsed[server_name] = not self._collapsed.get(server_name, False)
336
+ self._rebuild()
337
+
338
+ def _toggle_current_tool(self) -> None:
339
+ """Toggle the tool at the current cursor position."""
340
+ body = self.query_one("#dialog-body")
341
+ key = body.selected_key
342
+ if key is None or not key.startswith("__tool__"):
343
+ return
344
+ parts = key.split("__", 3)
345
+ if len(parts) < 4:
346
+ return
347
+ server_name = parts[2]
348
+ tool_name = parts[3]
349
+ discovered, included = self._server_tools.get(server_name, ([], set()))
350
+ if tool_name not in discovered:
351
+ return
352
+ if tool_name in included:
353
+ included.discard(tool_name)
354
+ else:
355
+ included.add(tool_name)
356
+ self._server_tools[server_name] = (discovered, included)
357
+ self._rebuild()
358
+
359
+ def _on_selected(self, key: str | None) -> None:
360
+ if key is None:
361
+ self.dismiss(None)
362
+ return
363
+ if key.startswith("__tool__"):
364
+ self._toggle_current_tool()
365
+ elif key.startswith("__srv__"):
366
+ server_name = key.split("__", 3)[2]
367
+ self._toggle_fold(server_name)
368
+
369
+ def action_toggle_check(self) -> None:
370
+ body = self.query_one("#dialog-body")
371
+ key = body.selected_key
372
+ if key and key.startswith("__srv__"):
373
+ server_name = key.split("__", 3)[2]
374
+ self._toggle_fold(server_name)
375
+ else:
376
+ self._toggle_current_tool()
377
+
378
+ def action_select_all(self) -> None:
379
+ """Toggle: select all tools across all servers, or deselect all."""
380
+ all_selected = True
381
+ for srv in self._servers:
382
+ if not srv.enabled:
383
+ continue
384
+ discovered, included = self._server_tools.get(srv.name, ([], set()))
385
+ if discovered and included != set(discovered):
386
+ all_selected = False
387
+ break
388
+ target = not all_selected
389
+ for srv in self._servers:
390
+ if not srv.enabled:
391
+ continue
392
+ discovered, _ = self._server_tools.get(srv.name, ([], set()))
393
+ if discovered:
394
+ self._server_tools[srv.name] = (
395
+ discovered,
396
+ set(discovered) if target else set(),
397
+ )
398
+ self._rebuild()
399
+
400
+ def _rebuild(self) -> None:
401
+ body = self.query_one("#dialog-body")
402
+ old_idx = body._selected_idx
403
+ items = self._build_item_list()
404
+ self._items = items
405
+ body.set_options(items, mark="", checkable=True)
406
+ body._selected_idx = min(old_idx, len(items) - 1) if items else 0
407
+ body._sync_hover()
@@ -0,0 +1,128 @@
1
+ """Model + thinking picker dialog — invoked by /model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from textual.app import ComposeResult
8
+
9
+ from synapse.models.registry import registry_from_settings, settings_thinking_label
10
+ from synapse.ui.dialogs.base import (
11
+ DialogBase,
12
+ OptionItem,
13
+ )
14
+
15
+ THINKING_LEVELS = ("off", "minimal", "low", "medium", "high", "max")
16
+
17
+
18
+ class ModelPickerDialog(DialogBase):
19
+ """Pick a model profile + thinking level.
20
+
21
+ dismiss result:
22
+ ("model", alias) → switch model, use default thinking
23
+ ("thinking", level) → change thinking only
24
+ """
25
+
26
+ _title_icon = "◆"
27
+
28
+ def __init__(self, settings: Any) -> None:
29
+ super().__init__()
30
+ self._settings = settings
31
+ try:
32
+ reg = registry_from_settings(settings)
33
+ current_model = getattr(settings, "active_model", None) or getattr(
34
+ reg, "default", None
35
+ )
36
+ current_think = settings_thinking_label(settings) or getattr(
37
+ settings, "reasoning_effort", "high"
38
+ )
39
+ model_names = list(reg.list_names())
40
+ allowed_think = list(reg.allowed_thinking_levels(current_model or ""))
41
+ if not allowed_think:
42
+ allowed_think = list(THINKING_LEVELS)
43
+ except Exception: # noqa: BLE001
44
+ reg = None
45
+ current_model = None
46
+ current_think = "high"
47
+ model_names = []
48
+ allowed_think = list(THINKING_LEVELS)
49
+
50
+ self._reg = reg
51
+ self._current_model = current_model
52
+ self._current_think = current_think
53
+ self._model_names = model_names
54
+ self._allowed_think = allowed_think
55
+ self._model_count = len(model_names)
56
+
57
+ @property
58
+ def title_text(self) -> str:
59
+ return "Select Model"
60
+
61
+ def compose_body(self) -> ComposeResult:
62
+ # Population happens in on_mount after body is queryable.
63
+ return
64
+ yield # pragma: no cover
65
+
66
+ def on_mount(self) -> None:
67
+ super().on_mount()
68
+ body = self.query_one("#dialog-body")
69
+ reg = self._reg
70
+ current = self._current_model
71
+ items: list[OptionItem] = []
72
+ # --- Model section ---
73
+ for name in self._model_names:
74
+ detail = ""
75
+ if reg is not None:
76
+ try:
77
+ p = reg.get(name)
78
+ detail = str(p.model or "")
79
+ except Exception: # noqa: BLE001
80
+ pass
81
+ # Keep one line: alias as label, provider model as trailing meta.
82
+ items.append(
83
+ OptionItem(
84
+ key=name,
85
+ label=name,
86
+ meta=detail,
87
+ selected=(name == current),
88
+ )
89
+ )
90
+ self._model_count = len(items)
91
+
92
+ # --- Thinking section ---
93
+ current_think = self._current_think or "high"
94
+ for level in self._allowed_think:
95
+ items.append(
96
+ OptionItem(
97
+ key=f"thinking:{level}",
98
+ label=level,
99
+ selected=(level == current_think),
100
+ )
101
+ )
102
+ body.set_options(items[: self._model_count], mark=" ")
103
+
104
+ # Mount thinking section header + items manually.
105
+ think_items = items[self._model_count :]
106
+ if think_items:
107
+ body.append_section("Thinking")
108
+ body.append_options(think_items, mark=" ")
109
+
110
+ def _on_apply(self) -> None:
111
+ body = self.query_one("#dialog-body")
112
+ key = body.selected_key
113
+ if not key:
114
+ self.dismiss(None)
115
+ return
116
+ self._dismiss_with(key)
117
+
118
+ def _on_selected(self, key: str | None) -> None:
119
+ if key:
120
+ self._dismiss_with(key)
121
+ else:
122
+ self.dismiss(None)
123
+
124
+ def _dismiss_with(self, key: str) -> None:
125
+ if key.startswith("thinking:"):
126
+ self.dismiss(("thinking", key.split(":", 1)[1]))
127
+ else:
128
+ self.dismiss(("model", key))
@@ -0,0 +1,63 @@
1
+ """Safety profile picker — invoked by /safety."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from textual.app import ComposeResult
8
+
9
+ from synapse.ui.dialogs.base import DialogBase, OptionItem, SectionHeader
10
+
11
+ PROFILES = {
12
+ "dev-autopass": "All tool calls pass automatically",
13
+ "dev-approve": "Each tool call requires confirmation",
14
+ "readonly": "Read-only mode, all writes blocked",
15
+ }
16
+
17
+
18
+ class SafetyPanelDialog(DialogBase):
19
+ """Pick a safety profile."""
20
+
21
+ _title_icon = "◇"
22
+
23
+ def __init__(self, settings: Any) -> None:
24
+ super().__init__()
25
+ self._settings = settings
26
+ self._current = getattr(settings, "safety_profile", "dev-autopass")
27
+
28
+ @property
29
+ def title_text(self) -> str:
30
+ return "Safety Profile"
31
+
32
+ def compose_body(self) -> ComposeResult:
33
+ yield SectionHeader("Profile")
34
+ items: list[OptionItem] = []
35
+ for key, desc in PROFILES.items():
36
+ items.append(
37
+ OptionItem(
38
+ key=key,
39
+ label=key,
40
+ detail=desc,
41
+ selected=(key == self._current),
42
+ )
43
+ )
44
+ self._items = items
45
+
46
+ def on_mount(self) -> None:
47
+ super().on_mount()
48
+ body = self.query_one("#dialog-body")
49
+ body.set_options(self._items, mark=" ")
50
+
51
+ def _on_apply(self) -> None:
52
+ body = self.query_one("#dialog-body")
53
+ key = body.selected_key
54
+ if key:
55
+ self.dismiss(("safety", key))
56
+ else:
57
+ self.dismiss(None)
58
+
59
+ def _on_selected(self, key: str | None) -> None:
60
+ if key:
61
+ self._on_apply()
62
+ else:
63
+ self.dismiss(None)
@@ -0,0 +1,98 @@
1
+ """Session list dialog — invoked by F4, /switch, /session delete."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from textual.app import ComposeResult
8
+
9
+ from synapse.ui.dialogs.base import DialogBase, OptionItem, SectionHeader
10
+
11
+
12
+ class SessionListDialog(DialogBase):
13
+ """List sessions for switching, single deletion, or multi-select deletion.
14
+
15
+ dismiss result:
16
+ ("switch", [thread_id]) → TUI should call /switch
17
+ ("delete", [thread_id]) → TUI should call /session delete (single)
18
+ ("multi_delete", [...] ) → TUI should batch delete
19
+ """
20
+
21
+ _title_icon = "\u2261" # ≡
22
+
23
+ def __init__(
24
+ self, settings: Any, *, current_thread: str, mode: str = "switch"
25
+ ) -> None:
26
+ super().__init__()
27
+ self._settings = settings
28
+ self._current_thread = current_thread
29
+ self._mode = mode # "switch" | "delete" | "multi_delete"
30
+ self._checkable = mode == "multi_delete"
31
+ if self._checkable:
32
+ self._title_keys = (
33
+ "\u2191\u2193 space toggle \u00b7 ctrl+a all \u00b7 "
34
+ "enter delete \u00b7 esc"
35
+ )
36
+ elif mode == "delete":
37
+ self._title_keys = "\u2191\u2193 enter delete \u00b7 esc"
38
+ try:
39
+ from synapse.sessions.store import SessionStore
40
+
41
+ store = SessionStore(settings.resolved_sessions_path())
42
+ self._sessions = store.list_nonempty(limit=50)
43
+ except Exception: # noqa: BLE001
44
+ self._sessions = []
45
+
46
+ @property
47
+ def title_text(self) -> str:
48
+ if self._checkable:
49
+ return "Delete Sessions (multi-select)"
50
+ return "Sessions" if self._mode == "switch" else "Delete Session"
51
+
52
+ def compose_body(self) -> ComposeResult:
53
+ if self._checkable:
54
+ yield SectionHeader(
55
+ "Select sessions to delete \u00b7 "
56
+ "Space=toggle Ctrl+A=all Enter=confirm"
57
+ )
58
+ elif self._mode == "switch":
59
+ yield SectionHeader("Select a session")
60
+ else:
61
+ yield SectionHeader("Select a session to delete")
62
+ items: list[OptionItem] = []
63
+ for s in self._sessions:
64
+ title = (s.title or "").strip() or s.thread_id[:8]
65
+ detail = f"{s.updated_at[:16] or '?'}"
66
+ items.append(
67
+ OptionItem(
68
+ key=s.thread_id,
69
+ label=title,
70
+ detail=detail,
71
+ selected=(s.thread_id == self._current_thread),
72
+ )
73
+ )
74
+ self._items = items
75
+
76
+ def on_mount(self) -> None:
77
+ super().on_mount()
78
+ body = self.query_one("#dialog-body")
79
+ body.set_options(self._items, mark=" ", checkable=self._checkable)
80
+
81
+ def _on_apply(self) -> None:
82
+ body = self.query_one("#dialog-body")
83
+ if self._checkable:
84
+ keys = body.checked_keys
85
+ if not keys:
86
+ self.dismiss(None)
87
+ return
88
+ self.dismiss((self._mode, keys))
89
+ else:
90
+ key = body.selected_key
91
+ if not key:
92
+ self.dismiss(None)
93
+ return
94
+ self.dismiss((self._mode, [key]))
95
+
96
+ def _on_selected(self, key: str | None) -> None:
97
+ # In multi-select mode, Enter also confirms (apply).
98
+ self._on_apply()