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,406 @@
1
+ """MCP slash-command handler and presentation helpers."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from synapse.app.agent import build_coding_agent
10
+ from synapse.commands.helpers import markdown_escape
11
+ from synapse.commands.result import SlashResult
12
+ from synapse.integrations.mcp_client import (
13
+ get_active_mcp_pool,
14
+ load_mcp_server_configs,
15
+ load_mcp_tools,
16
+ )
17
+
18
+
19
+ def _mcp_list_lines(settings: Any) -> list[str]:
20
+ servers = load_mcp_server_configs(
21
+ path=getattr(settings, "mcp_config_path", None),
22
+ json_blob=getattr(settings, "mcp_servers_json", None),
23
+ )
24
+ enable = bool(getattr(settings, "enable_mcp", True))
25
+ lines = [f"mcp enabled={enable}"]
26
+ pool = get_active_mcp_pool()
27
+ if pool is not None:
28
+ lines.append(f"live pool servers: {', '.join(pool.server_names) or '(none)'}")
29
+ if not servers:
30
+ lines.append("no MCP servers configured")
31
+ path = getattr(settings, "mcp_config_path", None)
32
+ if path:
33
+ lines.append(f"config path: {path}")
34
+ return lines
35
+ for s in servers:
36
+ if s.transport == "stdio":
37
+ dest = f"cmd={s.command!r} args={s.args!r}"
38
+ else:
39
+ dest = f"url={s.url!r}"
40
+ lines.append(f"- {s.name}: transport={s.transport} enabled={s.enabled} {dest}")
41
+ loaded = getattr(build_coding_agent, "last_mcp_servers", []) or []
42
+ warnings = getattr(build_coding_agent, "last_mcp_warnings", []) or []
43
+ tool_names = getattr(build_coding_agent, "last_mcp_tool_names", []) or []
44
+ lines.append(f"loaded at agent build: {', '.join(loaded) or '(none)'}")
45
+ lines.append(f"tools bound: {len(tool_names)}")
46
+ for w in warnings:
47
+ lines.append(f"warn: {w}")
48
+ return lines
49
+
50
+
51
+ def _md_mcp_list(settings: Any) -> str:
52
+ """Format MCP server config as a Markdown table."""
53
+ servers = load_mcp_server_configs(
54
+ path=getattr(settings, "mcp_config_path", None),
55
+ json_blob=getattr(settings, "mcp_servers_json", None),
56
+ )
57
+ enable = bool(getattr(settings, "enable_mcp", True))
58
+ lines = [f"## MCP Servers (`mcp enabled={enable}`)", ""]
59
+ pool = get_active_mcp_pool()
60
+ if pool is not None:
61
+ lines.append(f"**live pool**: {', '.join(pool.server_names) or '(none)'}")
62
+ lines.append("")
63
+ if not servers:
64
+ lines.append("*No MCP servers configured*")
65
+ path = getattr(settings, "mcp_config_path", None)
66
+ if path:
67
+ lines.append(f"\nconfig path: `{path}`")
68
+ return "\n".join(lines)
69
+ lines.append("| Name | Transport | Enabled | Endpoint |")
70
+ lines.append("|---|---|---|---|")
71
+ for s in servers:
72
+ if s.transport == "stdio":
73
+ dest = f"`{s.command or '-'}`"
74
+ if s.args:
75
+ dest += " " + " ".join(str(a) for a in s.args)
76
+ else:
77
+ dest = s.url or "-"
78
+ lines.append(
79
+ f"| {markdown_escape(s.name)} | {markdown_escape(s.transport)} "
80
+ f"| {'yes' if s.enabled else 'no'} | {markdown_escape(dest)} |"
81
+ )
82
+ loaded = getattr(build_coding_agent, "last_mcp_servers", []) or []
83
+ tool_names = getattr(build_coding_agent, "last_mcp_tool_names", []) or []
84
+ warnings_list = getattr(build_coding_agent, "last_mcp_warnings", []) or []
85
+ lines.append("")
86
+ lines.append(f"**loaded at agent build**: {', '.join(loaded) or '(none)'}")
87
+ lines.append(f"**tools bound**: {len(tool_names)}")
88
+ for w in warnings_list:
89
+ lines.append(f"- warn: {w}")
90
+ return "\n".join(lines)
91
+
92
+
93
+ def _md_tool_list(names: list[str], warnings: list[str] | None = None) -> str:
94
+ """Format MCP tool names as a Markdown list."""
95
+ lines = [f"## MCP Tools ({len(names)})", ""]
96
+ if not names:
97
+ lines.append("*(no tools discovered)*")
98
+ else:
99
+ for n in sorted(names):
100
+ lines.append(f"- `{markdown_escape(n)}`")
101
+ for w in warnings or []:
102
+ lines.append(f"\nwarn: {w}")
103
+ return "\n".join(lines)
104
+
105
+ def handle_mcp(
106
+ args: list[str],
107
+ *,
108
+ settings: Any,
109
+ agent: Any,
110
+ project_root: Path,
111
+ model_name: str | None,
112
+ rebuild_agent: Callable[..., Any],
113
+ ) -> SlashResult:
114
+ sub = (args[0].lower() if args else "list").strip()
115
+
116
+ if sub in {"list", "ls", "status"}:
117
+ lines = _mcp_list_lines(settings)
118
+ return SlashResult(handled=True, lines=lines, markdown=_md_mcp_list(settings))
119
+
120
+ if sub == "config":
121
+ path = getattr(settings, "mcp_config_path", None)
122
+ blob = getattr(settings, "mcp_servers_json", None)
123
+ lines = [
124
+ f"enable_mcp={getattr(settings, 'enable_mcp', True)}",
125
+ f"mcp_config_path={path!s}",
126
+ f"mcp_servers_json set={bool(blob and str(blob).strip())}",
127
+ "transports: stdio | sse | streamable_http(http)",
128
+ ]
129
+ if path and Path(path).is_file():
130
+ lines.append(f"config file exists: {path}")
131
+ elif path:
132
+ lines.append(f"config file missing: {path}")
133
+ file_status = ""
134
+ if path:
135
+ file_status = "exists" if Path(path).is_file() else "missing"
136
+ return SlashResult(
137
+ handled=True,
138
+ lines=lines,
139
+ markdown=(
140
+ "## MCP Config\n\n"
141
+ "| Setting | Value |\n|---|---|\n"
142
+ f"| enable_mcp | `{getattr(settings, 'enable_mcp', True)}` |\n"
143
+ f"| config_path | `{path}` |\n"
144
+ f"| config_json_set | `{bool(blob and str(blob).strip())}` |\n"
145
+ f"| transports | stdio, sse, streamable_http |\n"
146
+ + (f"| file_status | {file_status} |\n" if path else "")
147
+ ),
148
+ )
149
+
150
+ if sub == "tools":
151
+ names = getattr(build_coding_agent, "last_mcp_tool_names", []) or []
152
+ pool = get_active_mcp_pool()
153
+ if pool is not None and pool.tool_names:
154
+ names = list(pool.tool_names)
155
+ if not names:
156
+ # Fall back to a probe without replacing active pool permanently if empty.
157
+ servers = load_mcp_server_configs(
158
+ path=getattr(settings, "mcp_config_path", None),
159
+ json_blob=getattr(settings, "mcp_servers_json", None),
160
+ )
161
+ if not servers:
162
+ return SlashResult(handled=True, lines=["no MCP servers configured"])
163
+ if not getattr(settings, "enable_mcp", True):
164
+ return SlashResult(
165
+ handled=True,
166
+ lines=["mcp disabled; use /mcp enable then /mcp reload"],
167
+ error=True,
168
+ )
169
+ result = load_mcp_tools(servers, enabled=True)
170
+ names = list(result.tool_names or [getattr(t, "name", str(t)) for t in result.tools])
171
+ lines = [f"mcp tools ({len(names)}):"]
172
+ if not names:
173
+ lines.append("(no tools discovered)")
174
+ for n in names:
175
+ lines.append(f"- {n}")
176
+ for w in result.warnings:
177
+ lines.append(f"warn: {w}")
178
+ md = _md_tool_list(names, result.warnings)
179
+ return SlashResult(handled=True, lines=lines, markdown=md)
180
+ lines = [f"mcp tools ({len(names)}):"]
181
+ for n in names:
182
+ lines.append(f"- {n}")
183
+ return SlashResult(handled=True, lines=lines, markdown=_md_tool_list(names, []))
184
+
185
+ if sub == "test":
186
+ servers = load_mcp_server_configs(
187
+ path=getattr(settings, "mcp_config_path", None),
188
+ json_blob=getattr(settings, "mcp_servers_json", None),
189
+ )
190
+ if not servers:
191
+ return SlashResult(handled=True, lines=["no MCP servers configured"])
192
+ # Group by transport for clearer diagnostics.
193
+ by_transport: dict[str, list[str]] = {}
194
+ for s in servers:
195
+ by_transport.setdefault(s.transport, []).append(s.name)
196
+ result = load_mcp_tools(servers, enabled=True)
197
+ lines = [
198
+ f"servers ok: {', '.join(result.servers) or '-'}",
199
+ f"tools: {len(result.tools)}",
200
+ "configured transports:",
201
+ ]
202
+ for transport, names in sorted(by_transport.items()):
203
+ lines.append(f" {transport}: {', '.join(names)}")
204
+ for tool in result.tools[:30]:
205
+ lines.append(f"- {getattr(tool, 'name', tool)}")
206
+ if len(result.tools) > 30:
207
+ lines.append(f"... and {len(result.tools) - 30} more")
208
+ for w in result.warnings:
209
+ lines.append(f"warn: {w}")
210
+ # Build markdown
211
+ md_lines = ["## MCP Test Results", ""]
212
+ md_lines.append(f"- **servers ok**: {', '.join(result.servers) or '-'}")
213
+ md_lines.append(f"- **tools discovered**: {len(result.tools)}")
214
+ md_lines.append("")
215
+ md_lines.append("| Transport | Servers |")
216
+ md_lines.append("|---|---|")
217
+ for transport, names in sorted(by_transport.items()):
218
+ md_lines.append(
219
+ f"| {markdown_escape(transport)} | "
220
+ f"{markdown_escape(', '.join(names))} |"
221
+ )
222
+ if result.tools:
223
+ md_lines.append("")
224
+ md_lines.append(f"### Tools ({len(result.tools)})")
225
+ for tool in result.tools[:30]:
226
+ md_lines.append(f"- `{markdown_escape(getattr(tool, 'name', str(tool)))}`")
227
+ if len(result.tools) > 30:
228
+ md_lines.append(f"- *鈥?and {len(result.tools) - 30} more*")
229
+ for w in result.warnings:
230
+ md_lines.append(f"\nwarn: {w}")
231
+ return SlashResult(handled=True, lines=lines, markdown="\n".join(md_lines))
232
+
233
+ if sub == "toggle":
234
+ if len(args) < 2:
235
+ return SlashResult(
236
+ handled=True,
237
+ lines=["usage: /mcp toggle <server_name>"],
238
+ error=True,
239
+ )
240
+ target = args[1].strip()
241
+ servers = load_mcp_server_configs(
242
+ path=getattr(settings, "mcp_config_path", None),
243
+ json_blob=getattr(settings, "mcp_servers_json", None),
244
+ )
245
+ changed = None
246
+ for s in servers:
247
+ if s.name == target:
248
+ s.enabled = not s.enabled
249
+ changed = s
250
+ break
251
+ if changed is None:
252
+ return SlashResult(
253
+ handled=True,
254
+ lines=[f"mcp server not found: {target}"],
255
+ error=True,
256
+ )
257
+ # Serialize modified configs back so reload picks up the toggled state.
258
+ raw = {
259
+ "servers": [
260
+ {
261
+ "name": s.name,
262
+ "transport": s.transport,
263
+ "command": s.command,
264
+ "args": s.args,
265
+ "env": s.env,
266
+ "url": s.url,
267
+ "headers": s.headers,
268
+ "enabled": s.enabled,
269
+ "tool_prefix": s.tool_prefix,
270
+ "include_tools": s.include_tools,
271
+ "exclude_tools": s.exclude_tools,
272
+ }
273
+ for s in servers
274
+ ]
275
+ }
276
+ settings.mcp_servers_json = json.dumps(raw)
277
+ try:
278
+ new_agent = rebuild_agent(
279
+ settings,
280
+ project_root=project_root,
281
+ model_name=model_name,
282
+ agent=agent,
283
+ load_mcp=True,
284
+ )
285
+ except Exception as exc: # noqa: BLE001
286
+ return SlashResult(
287
+ handled=True,
288
+ lines=[f"mcp toggle reload failed: {exc}"],
289
+ error=True,
290
+ )
291
+ loaded = getattr(build_coding_agent, "last_mcp_servers", []) or []
292
+ warnings_list = getattr(build_coding_agent, "last_mcp_warnings", []) or []
293
+ tools = getattr(build_coding_agent, "last_mcp_tool_names", []) or []
294
+ notice = (
295
+ f"mcp '{target}' {'enabled' if changed.enabled else 'disabled'} 路 tools={len(tools)}"
296
+ )
297
+ lines = [
298
+ f"mcp server '{target}' {'enabled' if changed.enabled else 'disabled'}; agent rebuilt",
299
+ f"loaded servers: {', '.join(loaded) or '(none)'}",
300
+ f"tools bound: {len(tools)}",
301
+ ]
302
+ for w in warnings_list:
303
+ lines.append(f"warn: {w}")
304
+ md_lines = [
305
+ "## MCP Toggle",
306
+ "",
307
+ f"**{target}**: {'enabled' if changed.enabled else 'disabled'}",
308
+ "",
309
+ f"- loaded servers: {', '.join(loaded) or '(none)'}",
310
+ f"- tools bound: {len(tools)}",
311
+ ]
312
+ for w in warnings_list:
313
+ md_lines.append(f"- warn: {w}")
314
+ return SlashResult(
315
+ handled=True,
316
+ lines=lines,
317
+ notice=notice,
318
+ markdown="\n".join(md_lines),
319
+ agent=new_agent,
320
+ settings_changed=True,
321
+ )
322
+
323
+ if sub in {"enable", "on"}:
324
+ settings.enable_mcp = True
325
+ return SlashResult(
326
+ handled=True,
327
+ lines=["mcp enabled (run /mcp reload to rebuild agent)"],
328
+ notice="mcp enabled 路 reload pending",
329
+ settings_changed=True,
330
+ )
331
+
332
+ if sub in {"disable", "off"}:
333
+ settings.enable_mcp = False
334
+ try:
335
+ new_agent = rebuild_agent(
336
+ settings,
337
+ project_root=project_root,
338
+ model_name=model_name,
339
+ agent=agent,
340
+ load_mcp=False,
341
+ )
342
+ except Exception as exc: # noqa: BLE001
343
+ return SlashResult(
344
+ handled=True,
345
+ lines=[f"mcp disable rebuild failed: {exc}"],
346
+ error=True,
347
+ )
348
+ return SlashResult(
349
+ handled=True,
350
+ lines=["mcp disabled; agent rebuilt without MCP tools"],
351
+ notice="mcp disabled 路 tools=0",
352
+ agent=new_agent,
353
+ settings_changed=True,
354
+ )
355
+
356
+ if sub == "reload":
357
+ try:
358
+ new_agent = rebuild_agent(
359
+ settings,
360
+ project_root=project_root,
361
+ model_name=model_name,
362
+ agent=agent,
363
+ load_mcp=True,
364
+ )
365
+ except Exception as exc: # noqa: BLE001
366
+ return SlashResult(
367
+ handled=True,
368
+ lines=[f"mcp reload failed: {exc}"],
369
+ error=True,
370
+ )
371
+ loaded = getattr(build_coding_agent, "last_mcp_servers", []) or []
372
+ warnings = getattr(build_coding_agent, "last_mcp_warnings", []) or []
373
+ tools = getattr(build_coding_agent, "last_mcp_tool_names", []) or []
374
+ enabled_flag = bool(getattr(settings, "enable_mcp", True))
375
+ notice = f"mcp reloaded 路 servers={len(loaded)} tools={len(tools)}"
376
+ if not enabled_flag:
377
+ notice = "mcp reloaded 路 disabled"
378
+ lines = [
379
+ f"agent rebuilt; mcp enabled={getattr(settings, 'enable_mcp', True)}",
380
+ f"loaded servers: {', '.join(loaded) or '(none)'}",
381
+ f"tools bound: {len(tools)}",
382
+ ]
383
+ for w in warnings:
384
+ lines.append(f"warn: {w}")
385
+ md_lines = [
386
+ "## MCP Reloaded",
387
+ "",
388
+ f"- **enabled**: `{enabled_flag}`",
389
+ f"- **servers**: {', '.join(loaded) or '(none)'}",
390
+ f"- **tools**: {len(tools)}",
391
+ ]
392
+ for w in warnings:
393
+ md_lines.append(f"- warn: {w}")
394
+ return SlashResult(
395
+ handled=True,
396
+ lines=lines,
397
+ notice=notice,
398
+ markdown="\n".join(md_lines),
399
+ agent=new_agent,
400
+ )
401
+
402
+ return SlashResult(
403
+ handled=True,
404
+ lines=["usage: /mcp [list|tools|test|reload|enable|disable|config]"],
405
+ error=True,
406
+ )
@@ -0,0 +1,173 @@
1
+ """Model selection and thinking slash-command handler."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from synapse.commands.result import SlashResult
9
+ from synapse.models.registry import registry_from_settings
10
+
11
+
12
+ def handle_model(
13
+ args: list[str],
14
+ *,
15
+ settings: Any,
16
+ agent: Any,
17
+ project_root: Path,
18
+ thread_id: str | None = None,
19
+ apply_thinking_inplace: Callable[[Any, Any, str], bool],
20
+ rebuild_agent: Callable[..., Any],
21
+ persist_model_binding: Callable[[Any, str | None], None],
22
+ mcp_attach_pending: Callable[[Any], bool],
23
+ ) -> SlashResult:
24
+ from synapse.models.registry import (
25
+ apply_thinking_to_settings,
26
+ format_model_status,
27
+ is_thinking_token,
28
+ settings_thinking_label,
29
+ )
30
+
31
+ reg = registry_from_settings(settings)
32
+ cfg_path = getattr(settings, "models_config_path", None)
33
+ active = getattr(agent, "_coding_model_profile", None) or settings.active_model or reg.default
34
+ allowed = reg.allowed_thinking_levels(active)
35
+ allowed_help = "|".join(allowed) if allowed else "off|low|medium|high|max"
36
+
37
+ if not args:
38
+ lines = [
39
+ f"active={active}",
40
+ f"display={format_model_status(settings)}",
41
+ f"thinking={settings_thinking_label(settings)}",
42
+ f"thinking_levels={', '.join(allowed)}",
43
+ ]
44
+ if cfg_path:
45
+ lines.append(f"config={cfg_path}")
46
+ for name in reg.list_names():
47
+ p = reg.get(name)
48
+ mark = "*" if name == (settings.active_model or reg.default) else " "
49
+ base = f" base={p.base_url}" if p.base_url else ""
50
+ levels = reg.allowed_thinking_levels(name)
51
+ lines.append(
52
+ f"{mark} {name} -> {p.model} default_thinking={p.thinking_label()}"
53
+ f" levels=[{', '.join(levels)}]{base}"
54
+ )
55
+ lines.append("usage: /model <alias|provider:model> [thinking]")
56
+ lines.append(f" /model thinking <{allowed_help}>")
57
+ lines.append(" /model <alias> thinking <level>")
58
+ lines.append(
59
+ "note: thinking_levels are independent of model identity; "
60
+ "session thinking overrides profile default"
61
+ )
62
+ return SlashResult(handled=True, lines=lines)
63
+
64
+ # /model thinking <level>
65
+ if args[0].strip().casefold() in {"thinking", "effort", "reasoning"}:
66
+ if len(args) < 2:
67
+ return SlashResult(
68
+ handled=True,
69
+ lines=[f"usage: /model thinking <{allowed_help}>"],
70
+ error=True,
71
+ )
72
+ try:
73
+ label = apply_thinking_to_settings(settings, args[1], allowed=allowed)
74
+ except ValueError as exc:
75
+ return SlashResult(handled=True, lines=[str(exc)], error=True)
76
+ model_name = settings.active_model or reg.default
77
+ new_agent = None
78
+ note = ""
79
+ if apply_thinking_inplace(settings, agent, model_name):
80
+ note = " (live, no rebuild)"
81
+ else:
82
+ try:
83
+ new_agent = rebuild_agent(
84
+ settings,
85
+ project_root=project_root,
86
+ model_name=model_name,
87
+ agent=agent,
88
+ defer_mcp_reconnect=True,
89
+ )
90
+ except Exception as exc: # noqa: BLE001
91
+ return SlashResult(
92
+ handled=True,
93
+ lines=[f"thinking update failed: {exc}"],
94
+ error=True,
95
+ )
96
+ persist_model_binding(settings, thread_id)
97
+ return SlashResult(
98
+ handled=True,
99
+ lines=[f"thinking set to {label}{note} ({format_model_status(settings)})"],
100
+ agent=new_agent,
101
+ settings_changed=True,
102
+ mcp_attach_pending=bool(new_agent is not None and mcp_attach_pending(settings)),
103
+ )
104
+
105
+ target = args[0].strip()
106
+ try:
107
+ profile = reg.get(target)
108
+ except KeyError as exc:
109
+ return SlashResult(handled=True, lines=[str(exc)], error=True)
110
+
111
+ from synapse.models.registry import apply_profile_to_settings
112
+
113
+ apply_profile_to_settings(settings, profile, seed_thinking=True)
114
+
115
+ # /model <alias> high
116
+ # /model <alias> thinking high
117
+ think_raw: str | None = None
118
+ if len(args) >= 3 and args[1].strip().casefold() in {
119
+ "thinking",
120
+ "effort",
121
+ "reasoning",
122
+ }:
123
+ think_raw = args[2]
124
+ elif len(args) >= 2 and is_thinking_token(args[1]):
125
+ think_raw = args[1]
126
+ elif len(args) >= 2 and args[1].strip().casefold() not in {
127
+ "thinking",
128
+ "effort",
129
+ "reasoning",
130
+ }:
131
+ # Second arg present but not a known thinking token -> error for clarity
132
+ return SlashResult(
133
+ handled=True,
134
+ lines=[
135
+ f"unknown thinking level: {args[1]}",
136
+ f"usage: /model <alias> [{allowed_help}]",
137
+ ],
138
+ error=True,
139
+ )
140
+
141
+ if think_raw is not None:
142
+ try:
143
+ apply_thinking_to_settings(
144
+ settings,
145
+ think_raw,
146
+ allowed=reg.allowed_thinking_levels(profile.name),
147
+ )
148
+ except ValueError as exc:
149
+ return SlashResult(handled=True, lines=[str(exc)], error=True)
150
+
151
+ try:
152
+ new_agent = rebuild_agent(
153
+ settings,
154
+ project_root=project_root,
155
+ model_name=profile.name,
156
+ agent=agent,
157
+ defer_mcp_reconnect=True,
158
+ )
159
+ except Exception as exc: # noqa: BLE001
160
+ return SlashResult(
161
+ handled=True,
162
+ lines=[f"model switch failed: {exc}"],
163
+ error=True,
164
+ )
165
+ persist_model_binding(settings, thread_id)
166
+ mcp_attach_pending = mcp_attach_pending(settings)
167
+ return SlashResult(
168
+ handled=True,
169
+ lines=[f"model switched to {profile.name} ({format_model_status(settings)})"],
170
+ agent=new_agent,
171
+ settings_changed=True,
172
+ mcp_attach_pending=mcp_attach_pending,
173
+ )
@@ -0,0 +1,34 @@
1
+ """Shared command result contract."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class SlashResult:
10
+ """Outcome of a slash command."""
11
+
12
+ handled: bool = False
13
+ lines: list[str] = field(default_factory=list)
14
+ error: bool = False
15
+ # Short one-line confirmation for the bottom status bar (never transcript).
16
+ notice: str | None = None
17
+ exit_requested: bool = False
18
+ clear_log: bool = False
19
+ reload_transcript: bool = False
20
+ thread_id: str | None = None
21
+ agent: Any | None = None
22
+ settings_changed: bool = False
23
+ # TUI should attach MCP after applying the rebuilt agent, without blocking
24
+ # the slash command/model switch worker.
25
+ # Rich Markdown text rendered directly into the transcript (#log). When set,
26
+ # the TUI renders it as a Markdown block instead of plain lines.
27
+ markdown: str | None = None
28
+ mcp_attach_pending: bool = False
29
+ # UI theme switch (TUI should re-apply CSS / palette).
30
+ theme_name: str | None = None
31
+ # HITL: UI should resume the paused graph with this decision.
32
+ resume_action: str | None = None # "approve" | "reject"
33
+ resume_message: str | None = None
34
+