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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- 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()
|