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,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
|