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,816 @@
1
+ """Slash command completion candidates for TUI / CLI.
2
+
3
+ Returns full completed strings (not just suffixes) so Textual Input can
4
+ render ghost-text and accept with Right/Tab.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections import deque
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from synapse.integrations.mcp_client import load_mcp_server_configs
17
+ from synapse.models.registry import registry_from_settings
18
+ from synapse.sessions.store import SessionStore
19
+
20
+ ROOT_COMMANDS: list[str] = [
21
+ "/help",
22
+ "/?",
23
+ "/thread",
24
+ "/id",
25
+ "/clear",
26
+ "/exit",
27
+ "/quit",
28
+ "/sessions",
29
+ "/session",
30
+ "/new",
31
+ "/switch",
32
+ "/rename",
33
+ "/export",
34
+ "/compact",
35
+ "/context",
36
+ "/compression",
37
+ "/tool-output",
38
+ "/tool-compress",
39
+ "/safety",
40
+ "/approve",
41
+ "/reject",
42
+ "/skills",
43
+ "/memory",
44
+ "/subagents",
45
+ "/mcp",
46
+ "/model",
47
+ "/theme",
48
+ "/codex",
49
+ ]
50
+
51
+ SESSION_SUBCOMMANDS: list[str] = [
52
+ "list",
53
+ "ls",
54
+ "show",
55
+ "new",
56
+ "switch",
57
+ "rename",
58
+ "delete",
59
+ "search",
60
+ "export",
61
+ ]
62
+
63
+ MCP_SUBCOMMANDS: list[str] = [
64
+ "list",
65
+ "ls",
66
+ "status",
67
+ "tools",
68
+ "test",
69
+ "reload",
70
+ "enable",
71
+ "on",
72
+ "disable",
73
+ "off",
74
+ "config",
75
+ ]
76
+
77
+ EXPORT_FORMATS: list[str] = ["md", "json"]
78
+ CODEX_SUBCOMMANDS: list[str] = ["import"]
79
+ COMPRESSION_EXPORT_FORMATS: list[str] = ["json", "csv"]
80
+ COMPRESSION_SUBCOMMANDS: list[str] = [
81
+ "profile",
82
+ "export",
83
+ "events",
84
+ "requests",
85
+ "request",
86
+ "skipped",
87
+ "fallback",
88
+ "tool",
89
+ ]
90
+ COMPRESSION_SESSION_SUBCOMMANDS = frozenset(
91
+ {"profile", "events", "requests", "skipped", "fallback"}
92
+ )
93
+
94
+
95
+ @dataclass
96
+ class SessionChoice:
97
+ """One session option for switch/delete completion."""
98
+
99
+ thread_id: str
100
+ title: str = ""
101
+
102
+ def label(self) -> str:
103
+ title = (self.title or "").strip()
104
+ if not title or title == self.thread_id or title.startswith("session "):
105
+ return self.thread_id
106
+ short = title if len(title) <= 40 else title[:39] + "…"
107
+ return f"{self.thread_id} · {short}"
108
+
109
+ def matches(self, partial: str) -> bool:
110
+ p = " ".join((partial or "").strip().split()).casefold()
111
+ if not p:
112
+ return True
113
+ if self.thread_id.casefold().startswith(p):
114
+ return True
115
+ title = (self.title or "").strip().casefold()
116
+ if not title or title.startswith("session ") or title == self.thread_id.casefold():
117
+ return False
118
+ return title.startswith(p) or p in title
119
+
120
+
121
+ @dataclass
122
+ class SlashCompleteContext:
123
+ """Runtime data for dynamic completions."""
124
+
125
+ settings: Any | None = None
126
+ thread_ids: list[str] = field(default_factory=list)
127
+ session_titles: list[str] = field(default_factory=list)
128
+ sessions: list[SessionChoice] = field(default_factory=list)
129
+ model_names: list[str] = field(default_factory=list)
130
+ mcp_server_names: list[str] = field(default_factory=list)
131
+
132
+
133
+ def build_complete_context(settings: Any | None) -> SlashCompleteContext:
134
+ ctx = SlashCompleteContext(settings=settings)
135
+ if settings is None:
136
+ return ctx
137
+ try:
138
+ store = SessionStore(settings.resolved_sessions_path())
139
+ sessions = store.list(limit=50)
140
+ ctx.sessions = [SessionChoice(thread_id=s.thread_id, title=s.title or "") for s in sessions]
141
+ ctx.thread_ids = [s.thread_id for s in sessions]
142
+ ctx.session_titles = [
143
+ s.title
144
+ for s in sessions
145
+ if s.title and not s.title.startswith("session ") and s.title != s.thread_id
146
+ ]
147
+ except Exception: # noqa: BLE001
148
+ pass
149
+ try:
150
+ reg = registry_from_settings(settings)
151
+ ctx.model_names = list(reg.list_names())
152
+ except Exception: # noqa: BLE001
153
+ pass
154
+ try:
155
+ servers = load_mcp_server_configs(
156
+ path=getattr(settings, "mcp_config_path", None),
157
+ json_blob=getattr(settings, "mcp_servers_json", None),
158
+ )
159
+ ctx.mcp_server_names = [s.name for s in servers]
160
+ except Exception: # noqa: BLE001
161
+ pass
162
+ return ctx
163
+
164
+
165
+ def _sessions_from_ctx(ctx: SlashCompleteContext) -> list[SessionChoice]:
166
+ if ctx.sessions:
167
+ return list(ctx.sessions)
168
+ return [SessionChoice(thread_id=tid) for tid in ctx.thread_ids]
169
+
170
+
171
+ def _filter_sessions(sessions: list[SessionChoice], partial: str) -> list[SessionChoice]:
172
+ return [s for s in sessions if s.matches(partial)]
173
+
174
+
175
+ def _session_complete_lines(
176
+ cmd: str,
177
+ used: list[str],
178
+ sessions: list[SessionChoice],
179
+ *,
180
+ partial: str,
181
+ ) -> list[str]:
182
+ """Complete session refs by id/title; insert thread_id into the command line."""
183
+ matched = _filter_sessions(sessions, partial)
184
+ p = " ".join((partial or "").strip().split()).casefold()
185
+ id_first: list[str] = []
186
+ title_hits: list[str] = []
187
+ for s in matched:
188
+ line = " ".join([cmd, *used, s.thread_id]) if used else f"{cmd} {s.thread_id}"
189
+ if not p or s.thread_id.casefold().startswith(p):
190
+ id_first.append(line)
191
+ else:
192
+ title_hits.append(line)
193
+ return _unique_keep_order(id_first + title_hits)
194
+
195
+
196
+ def _filter_prefix(options: list[str], prefix: str, *, casefold: bool = True) -> list[str]:
197
+ if casefold:
198
+ p = prefix.casefold()
199
+ return [o for o in options if o.casefold().startswith(p)]
200
+ return [o for o in options if o.startswith(prefix)]
201
+
202
+
203
+ def _unique_keep_order(items: list[str]) -> list[str]:
204
+ seen: set[str] = set()
205
+ out: list[str] = []
206
+ for item in items:
207
+ if item in seen:
208
+ continue
209
+ seen.add(item)
210
+ out.append(item)
211
+ return out
212
+
213
+
214
+ def complete_slash(
215
+ value: str,
216
+ ctx: SlashCompleteContext | None = None,
217
+ ) -> list[str]:
218
+ """Return full-line completion candidates for the current input value."""
219
+ raw = value or ""
220
+ if not raw.startswith("/"):
221
+ return []
222
+
223
+ ctx = ctx or SlashCompleteContext()
224
+ trailing_space = raw.endswith(" ")
225
+ parts = raw.split()
226
+ if not parts:
227
+ return []
228
+ cmd = parts[0]
229
+ rest = parts[1:]
230
+
231
+ if not rest and not trailing_space:
232
+ return _unique_keep_order(_filter_prefix(ROOT_COMMANDS, cmd))
233
+
234
+ cmd_cf = cmd.casefold()
235
+
236
+ def with_prefix(options: list[str], used: list[str]) -> list[str]:
237
+ out: list[str] = []
238
+ for opt in options:
239
+ line = " ".join([cmd, *used, opt]) if used else f"{cmd} {opt}"
240
+ if line.casefold().startswith(raw.casefold()):
241
+ out.append(line)
242
+ return _unique_keep_order(out)
243
+
244
+ # /compression [session] | /compression <diagnostic> [args]
245
+ if cmd_cf in {"/compression", "/tool-output", "/tool-compress"}:
246
+ sessions = _sessions_from_ctx(ctx)
247
+ if not rest and trailing_space:
248
+ return with_prefix(COMPRESSION_SUBCOMMANDS, []) + _session_complete_lines(
249
+ cmd, [], sessions, partial=""
250
+ )
251
+ if len(rest) == 1 and not trailing_space:
252
+ options = with_prefix(
253
+ _filter_prefix(COMPRESSION_SUBCOMMANDS, rest[0]), []
254
+ ) + _session_complete_lines(
255
+ cmd,
256
+ [],
257
+ sessions,
258
+ partial=rest[0],
259
+ )
260
+ return [item for item in options if item.casefold().startswith(raw.casefold())]
261
+ sub = rest[0].casefold() if rest else ""
262
+ if sub == "export":
263
+ export_args = rest[1:]
264
+ used = [rest[0]]
265
+ if not export_args and trailing_space:
266
+ return with_prefix(
267
+ COMPRESSION_EXPORT_FORMATS, used
268
+ ) + _session_complete_lines(cmd, used, sessions, partial="")
269
+ if not export_args:
270
+ return []
271
+
272
+ first = export_args[0]
273
+ first_cf = first.casefold()
274
+ session_ids = {session.thread_id.casefold() for session in sessions}
275
+ if first_cf in {value.casefold() for value in COMPRESSION_EXPORT_FORMATS}:
276
+ return []
277
+ if first_cf in session_ids:
278
+ if len(export_args) == 1 and trailing_space:
279
+ return with_prefix(COMPRESSION_EXPORT_FORMATS, [*used, first])
280
+ if len(export_args) == 2 and not trailing_space:
281
+ return with_prefix(
282
+ _filter_prefix(COMPRESSION_EXPORT_FORMATS, export_args[1]),
283
+ [*used, first],
284
+ )
285
+ return []
286
+ if len(export_args) == 1 and not trailing_space:
287
+ options = with_prefix(
288
+ _filter_prefix(COMPRESSION_EXPORT_FORMATS, first), used
289
+ ) + _session_complete_lines(cmd, used, sessions, partial=first)
290
+ return [item for item in options if item.casefold().startswith(raw.casefold())]
291
+ if not trailing_space:
292
+ return _session_complete_lines(
293
+ cmd, used, sessions, partial=" ".join(export_args)
294
+ )
295
+ return []
296
+ if sub in COMPRESSION_SESSION_SUBCOMMANDS:
297
+ if len(rest) == 1 and trailing_space:
298
+ return _session_complete_lines(cmd, [rest[0]], sessions, partial="")
299
+ if len(rest) >= 2 and not trailing_space:
300
+ return _session_complete_lines(cmd, [rest[0]], sessions, partial=" ".join(rest[1:]))
301
+ if rest and not trailing_space:
302
+ return _session_complete_lines(cmd, [], sessions, partial=" ".join(rest))
303
+ return []
304
+
305
+ # /session <sub>
306
+ if cmd_cf == "/session":
307
+ if not rest and trailing_space:
308
+ return with_prefix(SESSION_SUBCOMMANDS, [])
309
+ if len(rest) == 1 and not trailing_space:
310
+ return with_prefix(_filter_prefix(SESSION_SUBCOMMANDS, rest[0]), [])
311
+ sub = rest[0].casefold() if rest else ""
312
+ if sub in {"switch", "delete", "show"}:
313
+ sessions = _sessions_from_ctx(ctx)
314
+ if len(rest) == 1 and trailing_space:
315
+ return _session_complete_lines(cmd, [rest[0]], sessions, partial="")
316
+ if len(rest) >= 2 and not trailing_space:
317
+ return _session_complete_lines(cmd, [rest[0]], sessions, partial=" ".join(rest[1:]))
318
+ return []
319
+ if sub == "export":
320
+ arg = rest[1] if len(rest) >= 2 else ""
321
+ if len(rest) == 1 and trailing_space:
322
+ return with_prefix(EXPORT_FORMATS, [rest[0]])
323
+ if len(rest) == 2 and not trailing_space:
324
+ return with_prefix(_filter_prefix(EXPORT_FORMATS, arg), [rest[0]])
325
+ if sub == "list" and len(rest) == 1 and trailing_space:
326
+ return [f"{cmd} list 20", f"{cmd} list 50"]
327
+ return []
328
+
329
+ # /sessions [list|search]
330
+ if cmd_cf == "/sessions":
331
+ subs = ["list", "search"]
332
+ if not rest and trailing_space:
333
+ return with_prefix(subs, [])
334
+ if len(rest) == 1 and not trailing_space:
335
+ return with_prefix(_filter_prefix(subs, rest[0]), [])
336
+ if rest and rest[0].casefold() == "search":
337
+ titles = ctx.session_titles
338
+ if len(rest) == 1 and trailing_space:
339
+ return [f"{cmd} search {t}" for t in titles[:10]]
340
+ if len(rest) >= 2 and not trailing_space:
341
+ partial = " ".join(rest[1:]).casefold()
342
+ return [
343
+ f"{cmd} search {t}"
344
+ for t in titles
345
+ if t.casefold().startswith(partial) or partial in t.casefold()
346
+ ][:10]
347
+ return []
348
+
349
+ # /switch <thread_id|title>
350
+ if cmd_cf == "/switch":
351
+ sessions = _sessions_from_ctx(ctx)
352
+ if not rest and trailing_space:
353
+ return _session_complete_lines(cmd, [], sessions, partial="")
354
+ if rest and not trailing_space:
355
+ return _session_complete_lines(cmd, [], sessions, partial=" ".join(rest))
356
+ return []
357
+
358
+ # /export [md|json]
359
+ if cmd_cf == "/export":
360
+ if not rest and trailing_space:
361
+ return with_prefix(EXPORT_FORMATS, [])
362
+ if len(rest) == 1 and not trailing_space:
363
+ return with_prefix(_filter_prefix(EXPORT_FORMATS, rest[0]), [])
364
+ return []
365
+
366
+ # /codex import [native_id]
367
+ if cmd_cf == "/codex":
368
+ if not rest and trailing_space:
369
+ return with_prefix(CODEX_SUBCOMMANDS, [])
370
+ if len(rest) == 1 and not trailing_space:
371
+ return with_prefix(_filter_prefix(CODEX_SUBCOMMANDS, rest[0]), [])
372
+ return []
373
+
374
+ # /mcp <sub>
375
+ if cmd_cf == "/mcp":
376
+ if not rest and trailing_space:
377
+ return with_prefix(MCP_SUBCOMMANDS, [])
378
+ if len(rest) == 1 and not trailing_space:
379
+ return with_prefix(_filter_prefix(MCP_SUBCOMMANDS, rest[0]), [])
380
+ return []
381
+
382
+ # /model <alias> [thinking-level] | /model thinking <level>
383
+ if cmd_cf == "/model":
384
+ names = list(ctx.model_names) + ["thinking", "effort"]
385
+ levels = ["off", "minimal", "low", "medium", "high", "max"]
386
+ if not rest and trailing_space:
387
+ return with_prefix(names, [])
388
+ if len(rest) == 1 and not trailing_space:
389
+ return with_prefix(_filter_prefix(names, rest[0]), [])
390
+ if len(rest) == 1 and trailing_space:
391
+ return with_prefix(levels + ["thinking"], [rest[0]])
392
+ if len(rest) == 2 and not trailing_space:
393
+ return with_prefix(_filter_prefix(levels + ["thinking"], rest[1]), [rest[0]])
394
+ if rest and rest[0].casefold() in {"thinking", "effort"}:
395
+ if len(rest) == 1 and trailing_space:
396
+ return with_prefix(levels, [rest[0]])
397
+ if len(rest) == 2 and not trailing_space:
398
+ return with_prefix(_filter_prefix(levels, rest[1]), [rest[0]])
399
+ if (
400
+ len(rest) >= 2
401
+ and rest[1].casefold() == "thinking"
402
+ and len(rest) == 2
403
+ and trailing_space
404
+ ):
405
+ return with_prefix(levels, rest[:2])
406
+ if len(rest) == 3 and rest[1].casefold() == "thinking" and not trailing_space:
407
+ return with_prefix(_filter_prefix(levels, rest[2]), rest[:2])
408
+ return []
409
+
410
+ # /theme [list|<name>]
411
+ if cmd_cf == "/theme":
412
+ try:
413
+ from synapse.ui.theme import list_theme_names
414
+
415
+ theme_names = list(list_theme_names())
416
+ except Exception: # noqa: BLE001
417
+ theme_names = []
418
+ options = ["list", "ls", *theme_names]
419
+ if not rest and trailing_space:
420
+ return with_prefix(options, [])
421
+ if len(rest) == 1 and not trailing_space:
422
+ return with_prefix(_filter_prefix(options, rest[0]), [])
423
+ return []
424
+
425
+ # /safety [profile]
426
+ if cmd_cf == "/safety":
427
+ profiles = ["dev-autopass", "dev-approve", "readonly", "hitl", "auto", "ro"]
428
+ if not rest and trailing_space:
429
+ return with_prefix(profiles, [])
430
+ if len(rest) == 1 and not trailing_space:
431
+ return with_prefix(_filter_prefix(profiles, rest[0]), [])
432
+ return []
433
+
434
+ # /rename <title>
435
+ if cmd_cf == "/rename":
436
+ titles = ctx.session_titles
437
+ if not rest and trailing_space:
438
+ return with_prefix(titles[:10], [])
439
+ partial = " ".join(rest)
440
+ if rest and not trailing_space:
441
+ return [f"{cmd} {t}" for t in titles if t.casefold().startswith(partial.casefold())][
442
+ :10
443
+ ]
444
+ return []
445
+
446
+ return []
447
+
448
+
449
+ def best_completion(value: str, ctx: SlashCompleteContext | None = None) -> str | None:
450
+ cands = complete_slash(value, ctx)
451
+ if not cands:
452
+ return None
453
+ return cands[0]
454
+
455
+
456
+ def cycle_completion(
457
+ value: str,
458
+ current: str | None,
459
+ ctx: SlashCompleteContext | None = None,
460
+ ) -> str | None:
461
+ """Return next candidate after current (wrap). If current not in list, first."""
462
+ cands = complete_slash(value, ctx)
463
+
464
+ if value in cands or (len(cands) == 1 and cands[0] == value):
465
+ if " " in value.rstrip():
466
+ parent = value.rstrip().rsplit(" ", 1)[0] + " "
467
+ siblings = complete_slash(parent, ctx)
468
+ if siblings:
469
+ cands = siblings
470
+ else:
471
+ prefix = value[:2] if value.startswith("/") else value
472
+ siblings = complete_slash(prefix, ctx)
473
+ if siblings:
474
+ cands = siblings
475
+
476
+ if not cands:
477
+ if " " in value.rstrip():
478
+ parent = value.rstrip().rsplit(" ", 1)[0] + " "
479
+ cands = complete_slash(parent, ctx)
480
+ if not cands:
481
+ return None
482
+
483
+ pivot = current if current in cands else (value if value in cands else None)
484
+ if pivot is not None:
485
+ idx = cands.index(pivot)
486
+ return cands[(idx + 1) % len(cands)]
487
+ return cands[0]
488
+
489
+
490
+ def format_completion_hint(
491
+ value: str,
492
+ ctx: SlashCompleteContext | None = None,
493
+ *,
494
+ limit: int = 8,
495
+ ) -> str:
496
+ ctx = ctx or SlashCompleteContext()
497
+ cands = complete_slash(value, ctx)
498
+ if not cands:
499
+ return ""
500
+
501
+ id_to_label = {s.thread_id: s.label() for s in _sessions_from_ctx(ctx)}
502
+ shown = cands[:limit]
503
+ tails: list[str] = []
504
+ for c in shown:
505
+ token = c.rsplit(" ", 1)[-1] if " " in c else c
506
+ if token in id_to_label and (
507
+ c.startswith("/switch ") or " switch " in c or " delete " in c or " show " in c
508
+ ):
509
+ tails.append(id_to_label[token])
510
+ continue
511
+ if c.casefold().startswith(value.casefold()) and len(c) > len(value):
512
+ tails.append(c[len(value) :])
513
+ else:
514
+ tails.append(c)
515
+ extra = f" +{len(cands) - limit}" if len(cands) > limit else ""
516
+ return "tab: " + " | ".join(tails) + extra
517
+
518
+
519
+ def make_textual_suggester(
520
+ context_provider: Callable[[], SlashCompleteContext],
521
+ workspace: str | Path | None = None,
522
+ ):
523
+ """Build a Textual Suggester instance (lazy import).
524
+
525
+ When *workspace* is provided the suggester also handles ``@``-prefixed
526
+ file/directory path completion.
527
+ """
528
+ from textual.suggester import Suggester
529
+
530
+ _ws = Path(workspace).resolve() if workspace else None
531
+
532
+ class SlashSuggester(Suggester):
533
+ def __init__(self) -> None:
534
+ # No cache: session/model lists change at runtime.
535
+ super().__init__(use_cache=False, case_sensitive=True)
536
+
537
+ async def get_suggestion(self, value: str) -> str | None:
538
+ if value.startswith("/"):
539
+ return best_completion(value, context_provider())
540
+ if _ws and "@" in value:
541
+ return best_at_completion(value, _ws)
542
+ return None
543
+
544
+ return SlashSuggester()
545
+
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # @ path completion — workspace-relative file/directory completions
549
+ # ---------------------------------------------------------------------------
550
+
551
+ _AT_HINT_LIMIT = 12
552
+ """Max candidates shown in the completion hint bar."""
553
+
554
+
555
+ def _find_at(value: str) -> tuple[int, str] | None:
556
+ """Find the last ``@`` and extract the path token after it.
557
+
558
+ Returns ``(at_index, path_prefix)``, e.g. for ``"cat @src/main"``
559
+ returns ``(4, "src/main")``. Returns ``None`` when there is no ``@``.
560
+ """
561
+ idx = value.rfind("@")
562
+ if idx < 0:
563
+ return None
564
+ # Everything after @ until end or next whitespace.
565
+ rest = value[idx + 1 :]
566
+ # Split on whitespace to get the path token.
567
+ parts = rest.split(maxsplit=1)
568
+ token = parts[0] if parts else ""
569
+ return idx, token
570
+
571
+
572
+ def _at_path_prefix(token: str) -> str:
573
+ """Normalise a user-typed path token to a relative ``Path`` string.
574
+
575
+ Strips leading ``./`` and normalises separators.
576
+ """
577
+ t = token.replace("\\", "/").lstrip("/")
578
+ if t.startswith("./"):
579
+ t = t[2:]
580
+ return t
581
+
582
+
583
+ _RECURSIVE_SCAN_LIMIT = 2000
584
+ """Max entries scanned during recursive fallback."""
585
+
586
+ # Directories skipped during recursive search (common VCS / cache / env dirs).
587
+ _SKIP_DIRS = frozenset(
588
+ {
589
+ ".git",
590
+ ".hg",
591
+ ".svn",
592
+ "__pycache__",
593
+ ".venv",
594
+ "venv",
595
+ ".tox",
596
+ "node_modules",
597
+ ".mypy_cache",
598
+ ".pytest_cache",
599
+ ".ruff_cache",
600
+ ".hypothesis",
601
+ ".eggs",
602
+ "build",
603
+ "dist",
604
+ ".idea",
605
+ ".vscode",
606
+ }
607
+ )
608
+
609
+
610
+ def _scandir_entries(
611
+ path: str,
612
+ *,
613
+ limit: int = 500,
614
+ ) -> list[os.DirEntry]:
615
+ """Return visible (non-dot) entries in *path*, sorted dirs-first."""
616
+ try:
617
+ with os.scandir(path) as it:
618
+ entries = [e for e in it if not e.name.startswith(".")]
619
+ entries.sort(key=lambda e: (not e.is_dir(), e.name.lower()))
620
+ if len(entries) > limit:
621
+ entries = entries[:limit]
622
+ return entries
623
+ except (PermissionError, OSError):
624
+ return []
625
+
626
+
627
+ def _glob_at_candidates(
628
+ token: str,
629
+ workspace: Path,
630
+ *,
631
+ limit: int = 100,
632
+ ) -> list[str]:
633
+ """Return matching relative path suffixes under *workspace*.
634
+
635
+ Results are relative to *workspace* so they can be plugged directly into
636
+ the ``@prefix`` slot. At most *limit* candidates are returned to avoid
637
+ excessive filesystem scanning.
638
+
639
+ When *token* ends with ``/`` (or ``\\``) the token is treated as a
640
+ directory and its immediate children are listed, so the user can drill
641
+ down without remembering exact file names.
642
+
643
+ When the user types a bare prefix (no directory separators) and direct
644
+ children produce few results, the function falls back to a BFS recursive
645
+ scan (using ``os.scandir`` with directory pruning) so that ``@syn`` can
646
+ match ``src/synapse/`` without requiring the user to type every level.
647
+ """
648
+ trailing_slash = token.endswith("/") or token.endswith("\\")
649
+ prefix = _at_path_prefix(token)
650
+
651
+ if prefix:
652
+ parent_path = Path(prefix)
653
+ if trailing_slash:
654
+ # Directory mode — list the children of this directory.
655
+ parent_part = parent_path.as_posix().rstrip("/")
656
+ base_part = ""
657
+ ls_dir = (workspace / parent_path).resolve()
658
+ else:
659
+ # File/partial mode — list parent dir, filter by base name.
660
+ parent_part = str(parent_path.parent).replace("\\", "/")
661
+ if parent_part == ".":
662
+ parent_part = ""
663
+ base_part = parent_path.name
664
+ ls_dir = (
665
+ (workspace / parent_path.parent).resolve() if parent_part else workspace.resolve()
666
+ )
667
+ else:
668
+ parent_part = ""
669
+ base_part = ""
670
+ ls_dir = workspace.resolve()
671
+
672
+ if not ls_dir.is_dir():
673
+ return []
674
+
675
+ seen: set[str] = set()
676
+ candidates: list[str] = []
677
+
678
+ # -- direct children first (fast path) --
679
+ entries = _scandir_entries(str(ls_dir))
680
+ for ent in entries:
681
+ if base_part and not ent.name.lower().startswith(base_part.lower()):
682
+ continue
683
+ suffix = ent.name + ("/" if ent.is_dir(follow_symlinks=False) else "")
684
+ rel = f"{parent_part}/{suffix}" if parent_part else suffix
685
+ rel = rel.replace("\\", "/")
686
+ if rel.startswith("./"):
687
+ rel = rel[2:]
688
+ if rel not in seen:
689
+ seen.add(rel)
690
+ candidates.append(rel)
691
+ if len(candidates) >= limit:
692
+ return candidates
693
+
694
+ # -- recursive fallback via os.scandir BFS with directory pruning --
695
+ # Only when the user typed a bare prefix and is not browsing a directory.
696
+ if trailing_slash:
697
+ return candidates
698
+ if not base_part:
699
+ return candidates
700
+ if len(base_part) < 3: # Require >= 3 chars to avoid expensive wide scans.
701
+ return candidates
702
+ if len(candidates) >= limit // 2:
703
+ return candidates
704
+
705
+ _base_lower = base_part.lower()
706
+
707
+ # BFS queue: (absolute-path, rel-prefix). BFS finds shallower matches first.
708
+ _q: deque[tuple[str, str]] = deque()
709
+ _q.append((str(ls_dir), parent_part))
710
+ _scanned = 0
711
+
712
+ while _q and len(candidates) < limit:
713
+ dir_path, rel_prefix = _q.popleft()
714
+ try:
715
+ with os.scandir(dir_path) as it:
716
+ for entry in it:
717
+ _scanned += 1
718
+ if _scanned > _RECURSIVE_SCAN_LIMIT:
719
+ break
720
+ name = entry.name
721
+ if name.startswith("."):
722
+ continue
723
+
724
+ child_rel = f"{rel_prefix}/{name}" if rel_prefix else name
725
+ child_rel = child_rel.replace("\\", "/")
726
+
727
+ try:
728
+ is_dir = entry.is_dir(follow_symlinks=False)
729
+ except OSError:
730
+ continue
731
+
732
+ if is_dir:
733
+ if name in _SKIP_DIRS:
734
+ continue
735
+ _q.append((entry.path, child_rel))
736
+ if name.lower().startswith(_base_lower):
737
+ key = child_rel + "/"
738
+ if key not in seen:
739
+ seen.add(key)
740
+ candidates.append(key)
741
+ if len(candidates) >= limit:
742
+ break
743
+ elif name.lower().startswith(_base_lower):
744
+ key = child_rel
745
+ if key not in seen:
746
+ seen.add(key)
747
+ candidates.append(key)
748
+ if len(candidates) >= limit:
749
+ break
750
+ except (PermissionError, OSError):
751
+ continue
752
+
753
+ # Sort: directories first, then by path.
754
+ candidates.sort(key=lambda c: (not c.endswith("/"), c.lower()))
755
+ return candidates[:limit]
756
+
757
+
758
+ def complete_at_line(value: str, workspace: Path) -> list[str]:
759
+ """Return full-line completion candidates for an ``@`` path reference.
760
+
761
+ Each candidate is the original *value* with the ``@token`` portion
762
+ replaced by the matched path.
763
+ """
764
+ found = _find_at(value)
765
+ if found is None:
766
+ return []
767
+ at_idx, token = found
768
+ cands = _glob_at_candidates(token, workspace)
769
+ if not cands:
770
+ return []
771
+ prefix = value[:at_idx] # everything before @
772
+ # Build full-line replacement for every candidate.
773
+ result: list[str] = []
774
+ for c in cands:
775
+ result.append(f"{prefix}@{c}")
776
+ return result
777
+
778
+
779
+ def best_at_completion(value: str, workspace: Path) -> str | None:
780
+ """Return the first match for ``@`` path completion, or None."""
781
+ cands = complete_at_line(value, workspace)
782
+ return cands[0] if cands else None
783
+
784
+
785
+ def cycle_at_completion(
786
+ value: str,
787
+ current: str | None,
788
+ workspace: Path,
789
+ ) -> str | None:
790
+ """Return next candidate after *current* (wraps around).
791
+
792
+ When *current* is not in the candidate list, return the first candidate.
793
+ """
794
+ cands = complete_at_line(value, workspace)
795
+ if not cands:
796
+ return None
797
+ if current in cands:
798
+ idx = cands.index(current)
799
+ return cands[(idx + 1) % len(cands)]
800
+ return cands[0]
801
+
802
+
803
+ def format_at_hint(value: str, workspace: Path, *, limit: int = _AT_HINT_LIMIT) -> str:
804
+ """Return a short hint string for the completion bar."""
805
+ cands = complete_at_line(value, workspace)
806
+ if not cands:
807
+ return ""
808
+ shown = cands[:limit]
809
+ # Extract only the tail after @ for compact display.
810
+ at_idx, _ = _find_at(value)
811
+ tails: list[str] = []
812
+ for c in shown:
813
+ tail = c[at_idx:] if at_idx is not None else c
814
+ tails.append(tail)
815
+ extra = f" +{len(cands) - limit}" if len(cands) > limit else ""
816
+ return "tab: " + " | ".join(tails) + extra