glaip-sdk 0.6.12__py3-none-any.whl → 0.6.15__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.
- glaip_sdk/__init__.py +42 -5
- {glaip_sdk-0.6.12.dist-info → glaip_sdk-0.6.15.dist-info}/METADATA +32 -37
- glaip_sdk-0.6.15.dist-info/RECORD +12 -0
- {glaip_sdk-0.6.12.dist-info → glaip_sdk-0.6.15.dist-info}/WHEEL +2 -1
- glaip_sdk-0.6.15.dist-info/entry_points.txt +2 -0
- glaip_sdk-0.6.15.dist-info/top_level.txt +1 -0
- glaip_sdk/agents/__init__.py +0 -27
- glaip_sdk/agents/base.py +0 -1191
- glaip_sdk/cli/__init__.py +0 -9
- glaip_sdk/cli/account_store.py +0 -540
- glaip_sdk/cli/agent_config.py +0 -78
- glaip_sdk/cli/auth.py +0 -699
- glaip_sdk/cli/commands/__init__.py +0 -5
- glaip_sdk/cli/commands/accounts.py +0 -746
- glaip_sdk/cli/commands/agents.py +0 -1509
- glaip_sdk/cli/commands/common_config.py +0 -101
- glaip_sdk/cli/commands/configure.py +0 -896
- glaip_sdk/cli/commands/mcps.py +0 -1356
- glaip_sdk/cli/commands/models.py +0 -69
- glaip_sdk/cli/commands/tools.py +0 -576
- glaip_sdk/cli/commands/transcripts.py +0 -755
- glaip_sdk/cli/commands/update.py +0 -61
- glaip_sdk/cli/config.py +0 -95
- glaip_sdk/cli/constants.py +0 -38
- glaip_sdk/cli/context.py +0 -150
- glaip_sdk/cli/core/__init__.py +0 -79
- glaip_sdk/cli/core/context.py +0 -124
- glaip_sdk/cli/core/output.py +0 -846
- glaip_sdk/cli/core/prompting.py +0 -649
- glaip_sdk/cli/core/rendering.py +0 -187
- glaip_sdk/cli/display.py +0 -355
- glaip_sdk/cli/hints.py +0 -57
- glaip_sdk/cli/io.py +0 -112
- glaip_sdk/cli/main.py +0 -604
- glaip_sdk/cli/masking.py +0 -136
- glaip_sdk/cli/mcp_validators.py +0 -287
- glaip_sdk/cli/pager.py +0 -266
- glaip_sdk/cli/parsers/__init__.py +0 -7
- glaip_sdk/cli/parsers/json_input.py +0 -177
- glaip_sdk/cli/resolution.py +0 -67
- glaip_sdk/cli/rich_helpers.py +0 -27
- glaip_sdk/cli/slash/__init__.py +0 -15
- glaip_sdk/cli/slash/accounts_controller.py +0 -578
- glaip_sdk/cli/slash/accounts_shared.py +0 -75
- glaip_sdk/cli/slash/agent_session.py +0 -285
- glaip_sdk/cli/slash/prompt.py +0 -256
- glaip_sdk/cli/slash/remote_runs_controller.py +0 -566
- glaip_sdk/cli/slash/session.py +0 -1708
- glaip_sdk/cli/slash/tui/__init__.py +0 -9
- glaip_sdk/cli/slash/tui/accounts_app.py +0 -876
- glaip_sdk/cli/slash/tui/background_tasks.py +0 -72
- glaip_sdk/cli/slash/tui/loading.py +0 -58
- glaip_sdk/cli/slash/tui/remote_runs_app.py +0 -628
- glaip_sdk/cli/transcript/__init__.py +0 -31
- glaip_sdk/cli/transcript/cache.py +0 -536
- glaip_sdk/cli/transcript/capture.py +0 -329
- glaip_sdk/cli/transcript/export.py +0 -38
- glaip_sdk/cli/transcript/history.py +0 -815
- glaip_sdk/cli/transcript/launcher.py +0 -77
- glaip_sdk/cli/transcript/viewer.py +0 -374
- glaip_sdk/cli/update_notifier.py +0 -290
- glaip_sdk/cli/utils.py +0 -263
- glaip_sdk/cli/validators.py +0 -238
- glaip_sdk/client/__init__.py +0 -11
- glaip_sdk/client/_agent_payloads.py +0 -520
- glaip_sdk/client/agent_runs.py +0 -147
- glaip_sdk/client/agents.py +0 -1335
- glaip_sdk/client/base.py +0 -502
- glaip_sdk/client/main.py +0 -249
- glaip_sdk/client/mcps.py +0 -370
- glaip_sdk/client/run_rendering.py +0 -700
- glaip_sdk/client/shared.py +0 -21
- glaip_sdk/client/tools.py +0 -661
- glaip_sdk/client/validators.py +0 -198
- glaip_sdk/config/constants.py +0 -52
- glaip_sdk/mcps/__init__.py +0 -21
- glaip_sdk/mcps/base.py +0 -345
- glaip_sdk/models/__init__.py +0 -90
- glaip_sdk/models/agent.py +0 -47
- glaip_sdk/models/agent_runs.py +0 -116
- glaip_sdk/models/common.py +0 -42
- glaip_sdk/models/mcp.py +0 -33
- glaip_sdk/models/tool.py +0 -33
- glaip_sdk/payload_schemas/__init__.py +0 -7
- glaip_sdk/payload_schemas/agent.py +0 -85
- glaip_sdk/registry/__init__.py +0 -55
- glaip_sdk/registry/agent.py +0 -164
- glaip_sdk/registry/base.py +0 -139
- glaip_sdk/registry/mcp.py +0 -253
- glaip_sdk/registry/tool.py +0 -232
- glaip_sdk/runner/__init__.py +0 -59
- glaip_sdk/runner/base.py +0 -84
- glaip_sdk/runner/deps.py +0 -115
- glaip_sdk/runner/langgraph.py +0 -782
- glaip_sdk/runner/mcp_adapter/__init__.py +0 -13
- glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +0 -43
- glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +0 -257
- glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +0 -95
- glaip_sdk/runner/tool_adapter/__init__.py +0 -18
- glaip_sdk/runner/tool_adapter/base_tool_adapter.py +0 -44
- glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +0 -219
- glaip_sdk/tools/__init__.py +0 -22
- glaip_sdk/tools/base.py +0 -435
- glaip_sdk/utils/__init__.py +0 -86
- glaip_sdk/utils/a2a/__init__.py +0 -34
- glaip_sdk/utils/a2a/event_processor.py +0 -188
- glaip_sdk/utils/agent_config.py +0 -194
- glaip_sdk/utils/bundler.py +0 -267
- glaip_sdk/utils/client.py +0 -111
- glaip_sdk/utils/client_utils.py +0 -486
- glaip_sdk/utils/datetime_helpers.py +0 -58
- glaip_sdk/utils/discovery.py +0 -78
- glaip_sdk/utils/display.py +0 -135
- glaip_sdk/utils/export.py +0 -143
- glaip_sdk/utils/general.py +0 -61
- glaip_sdk/utils/import_export.py +0 -168
- glaip_sdk/utils/import_resolver.py +0 -492
- glaip_sdk/utils/instructions.py +0 -101
- glaip_sdk/utils/rendering/__init__.py +0 -115
- glaip_sdk/utils/rendering/formatting.py +0 -264
- glaip_sdk/utils/rendering/layout/__init__.py +0 -64
- glaip_sdk/utils/rendering/layout/panels.py +0 -156
- glaip_sdk/utils/rendering/layout/progress.py +0 -202
- glaip_sdk/utils/rendering/layout/summary.py +0 -74
- glaip_sdk/utils/rendering/layout/transcript.py +0 -606
- glaip_sdk/utils/rendering/models.py +0 -85
- glaip_sdk/utils/rendering/renderer/__init__.py +0 -55
- glaip_sdk/utils/rendering/renderer/base.py +0 -1024
- glaip_sdk/utils/rendering/renderer/config.py +0 -27
- glaip_sdk/utils/rendering/renderer/console.py +0 -55
- glaip_sdk/utils/rendering/renderer/debug.py +0 -178
- glaip_sdk/utils/rendering/renderer/factory.py +0 -138
- glaip_sdk/utils/rendering/renderer/stream.py +0 -202
- glaip_sdk/utils/rendering/renderer/summary_window.py +0 -79
- glaip_sdk/utils/rendering/renderer/thinking.py +0 -273
- glaip_sdk/utils/rendering/renderer/toggle.py +0 -182
- glaip_sdk/utils/rendering/renderer/tool_panels.py +0 -442
- glaip_sdk/utils/rendering/renderer/transcript_mode.py +0 -162
- glaip_sdk/utils/rendering/state.py +0 -204
- glaip_sdk/utils/rendering/step_tree_state.py +0 -100
- glaip_sdk/utils/rendering/steps/__init__.py +0 -34
- glaip_sdk/utils/rendering/steps/event_processor.py +0 -778
- glaip_sdk/utils/rendering/steps/format.py +0 -176
- glaip_sdk/utils/rendering/steps/manager.py +0 -387
- glaip_sdk/utils/rendering/timing.py +0 -36
- glaip_sdk/utils/rendering/viewer/__init__.py +0 -21
- glaip_sdk/utils/rendering/viewer/presenter.py +0 -184
- glaip_sdk/utils/resource_refs.py +0 -195
- glaip_sdk/utils/run_renderer.py +0 -41
- glaip_sdk/utils/runtime_config.py +0 -425
- glaip_sdk/utils/serialization.py +0 -424
- glaip_sdk/utils/sync.py +0 -142
- glaip_sdk/utils/tool_detection.py +0 -33
- glaip_sdk/utils/validation.py +0 -264
- glaip_sdk-0.6.12.dist-info/RECORD +0 -159
- glaip_sdk-0.6.12.dist-info/entry_points.txt +0 -3
glaip_sdk/cli/core/prompting.py
DELETED
|
@@ -1,649 +0,0 @@
|
|
|
1
|
-
"""CLI prompting utilities: prompt_toolkit + questionary wrappers, validators.
|
|
2
|
-
|
|
3
|
-
Authors:
|
|
4
|
-
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
-
Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import asyncio
|
|
11
|
-
import logging
|
|
12
|
-
import os
|
|
13
|
-
import re
|
|
14
|
-
from collections.abc import Iterable
|
|
15
|
-
from pathlib import Path
|
|
16
|
-
from typing import Any
|
|
17
|
-
|
|
18
|
-
from glaip_sdk.icons import ICON_AGENT
|
|
19
|
-
from rich.console import Console
|
|
20
|
-
|
|
21
|
-
questionary = None # type: ignore[assignment]
|
|
22
|
-
|
|
23
|
-
# Optional interactive deps (fuzzy palette)
|
|
24
|
-
try:
|
|
25
|
-
from prompt_toolkit.buffer import Buffer
|
|
26
|
-
from prompt_toolkit.completion import Completion
|
|
27
|
-
from prompt_toolkit.patch_stdout import patch_stdout as pt_patch_stdout
|
|
28
|
-
from prompt_toolkit.selection import SelectionType
|
|
29
|
-
from prompt_toolkit.shortcuts import PromptSession, prompt
|
|
30
|
-
|
|
31
|
-
_HAS_PTK = True
|
|
32
|
-
except Exception: # pragma: no cover - optional dependency
|
|
33
|
-
Buffer = None # type: ignore[assignment]
|
|
34
|
-
SelectionType = None # type: ignore[assignment]
|
|
35
|
-
PromptSession = None # type: ignore[assignment]
|
|
36
|
-
prompt = None # type: ignore[assignment]
|
|
37
|
-
pt_patch_stdout = None # type: ignore[assignment]
|
|
38
|
-
_HAS_PTK = False
|
|
39
|
-
|
|
40
|
-
logger = logging.getLogger("glaip_sdk.cli.core.prompting")
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _load_questionary_module() -> tuple[Any | None, Any | None]:
|
|
44
|
-
"""Return the questionary module and Choice class if available."""
|
|
45
|
-
module = questionary
|
|
46
|
-
if module is not None:
|
|
47
|
-
return module, getattr(module, "Choice", None)
|
|
48
|
-
|
|
49
|
-
try: # pragma: no cover - optional dependency
|
|
50
|
-
module = __import__("questionary")
|
|
51
|
-
except ImportError:
|
|
52
|
-
return None, None
|
|
53
|
-
|
|
54
|
-
return module, getattr(module, "Choice", None)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def _make_questionary_choice(choice_cls: Any | None, **kwargs: Any) -> Any:
|
|
58
|
-
"""Create a questionary Choice instance or lightweight fallback."""
|
|
59
|
-
if choice_cls is None:
|
|
60
|
-
return kwargs
|
|
61
|
-
return choice_cls(**kwargs)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def prompt_export_choice_questionary(
|
|
65
|
-
default_path: Path,
|
|
66
|
-
default_display: str,
|
|
67
|
-
) -> tuple[str, Path | None] | None:
|
|
68
|
-
"""Prompt user for export destination using questionary with numeric shortcuts.
|
|
69
|
-
|
|
70
|
-
Args:
|
|
71
|
-
default_path: Default export path.
|
|
72
|
-
default_display: Formatted display string for default path.
|
|
73
|
-
|
|
74
|
-
Returns:
|
|
75
|
-
Tuple of (choice, path) or None if cancelled/unavailable.
|
|
76
|
-
Choice can be "default", "custom", or "cancel".
|
|
77
|
-
"""
|
|
78
|
-
questionary_module, choice_cls = _load_questionary_module()
|
|
79
|
-
if questionary_module is None or choice_cls is None:
|
|
80
|
-
return None
|
|
81
|
-
|
|
82
|
-
try:
|
|
83
|
-
question = questionary_module.select(
|
|
84
|
-
"Export transcript",
|
|
85
|
-
choices=[
|
|
86
|
-
_make_questionary_choice(
|
|
87
|
-
choice_cls,
|
|
88
|
-
title=f"Save to default ({default_display})",
|
|
89
|
-
value=("default", default_path),
|
|
90
|
-
shortcut_key="1",
|
|
91
|
-
),
|
|
92
|
-
_make_questionary_choice(
|
|
93
|
-
choice_cls,
|
|
94
|
-
title="Choose a different path",
|
|
95
|
-
value=("custom", None),
|
|
96
|
-
shortcut_key="2",
|
|
97
|
-
),
|
|
98
|
-
_make_questionary_choice(
|
|
99
|
-
choice_cls,
|
|
100
|
-
title="Cancel",
|
|
101
|
-
value=("cancel", None),
|
|
102
|
-
shortcut_key="3",
|
|
103
|
-
),
|
|
104
|
-
],
|
|
105
|
-
use_shortcuts=True,
|
|
106
|
-
instruction="Press 1-3 (or arrows) then Enter.",
|
|
107
|
-
)
|
|
108
|
-
answer = questionary_safe_ask(question)
|
|
109
|
-
except Exception:
|
|
110
|
-
return None
|
|
111
|
-
|
|
112
|
-
if answer is None:
|
|
113
|
-
return ("cancel", None)
|
|
114
|
-
return answer
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def questionary_safe_ask(question: Any, *, patch_stdout: bool = False) -> Any:
|
|
118
|
-
"""Run `questionary.Question` safely even when an asyncio loop is active."""
|
|
119
|
-
ask_fn = getattr(question, "unsafe_ask", None)
|
|
120
|
-
if not callable(ask_fn):
|
|
121
|
-
raise RuntimeError("Questionary prompt is missing unsafe_ask()")
|
|
122
|
-
|
|
123
|
-
if not _asyncio_loop_running():
|
|
124
|
-
return ask_fn(patch_stdout=patch_stdout)
|
|
125
|
-
|
|
126
|
-
return _run_questionary_in_thread(question, patch_stdout=patch_stdout)
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _asyncio_loop_running() -> bool:
|
|
130
|
-
"""Return True when an asyncio event loop is already running."""
|
|
131
|
-
try:
|
|
132
|
-
asyncio.get_running_loop()
|
|
133
|
-
except RuntimeError:
|
|
134
|
-
return False
|
|
135
|
-
return True
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _run_questionary_in_thread(question: Any, *, patch_stdout: bool = False) -> Any:
|
|
139
|
-
"""Execute a questionary prompt in a background thread."""
|
|
140
|
-
if getattr(question, "should_skip_question", False):
|
|
141
|
-
return getattr(question, "default", None)
|
|
142
|
-
|
|
143
|
-
application = getattr(question, "application", None)
|
|
144
|
-
run_callable = getattr(application, "run", None) if application is not None else None
|
|
145
|
-
if callable(run_callable):
|
|
146
|
-
try:
|
|
147
|
-
if patch_stdout and pt_patch_stdout is not None:
|
|
148
|
-
with pt_patch_stdout():
|
|
149
|
-
return run_callable(in_thread=True)
|
|
150
|
-
return run_callable(in_thread=True)
|
|
151
|
-
except TypeError:
|
|
152
|
-
pass
|
|
153
|
-
|
|
154
|
-
return question.unsafe_ask(patch_stdout=patch_stdout)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def _basic_prompt(
|
|
158
|
-
message: str,
|
|
159
|
-
completer: Any,
|
|
160
|
-
) -> str | None:
|
|
161
|
-
"""Fallback prompt handler when PromptSession is unavailable or fails."""
|
|
162
|
-
if prompt is None: # pragma: no cover - optional dependency path
|
|
163
|
-
return None
|
|
164
|
-
|
|
165
|
-
try:
|
|
166
|
-
return prompt(
|
|
167
|
-
message=message,
|
|
168
|
-
completer=completer,
|
|
169
|
-
complete_in_thread=True,
|
|
170
|
-
complete_while_typing=True,
|
|
171
|
-
)
|
|
172
|
-
except (KeyboardInterrupt, EOFError):
|
|
173
|
-
return None
|
|
174
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
175
|
-
logger.debug("Fallback prompt failed: %s", exc)
|
|
176
|
-
return None
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def _prompt_with_auto_select(
|
|
180
|
-
message: str,
|
|
181
|
-
completer: Any,
|
|
182
|
-
choices: Iterable[str],
|
|
183
|
-
) -> str | None:
|
|
184
|
-
"""Prompt with fuzzy completer that auto-selects suggested matches."""
|
|
185
|
-
if not _HAS_PTK or PromptSession is None or Buffer is None or SelectionType is None:
|
|
186
|
-
return _basic_prompt(message, completer)
|
|
187
|
-
|
|
188
|
-
try:
|
|
189
|
-
session = PromptSession(
|
|
190
|
-
message,
|
|
191
|
-
completer=completer,
|
|
192
|
-
complete_in_thread=True,
|
|
193
|
-
complete_while_typing=True,
|
|
194
|
-
reserve_space_for_menu=8,
|
|
195
|
-
)
|
|
196
|
-
except Exception as exc: # pragma: no cover - depends on prompt_toolkit
|
|
197
|
-
logger.debug("PromptSession init failed (%s); falling back to basic prompt.", exc)
|
|
198
|
-
return _basic_prompt(message, completer)
|
|
199
|
-
|
|
200
|
-
buffer = session.default_buffer
|
|
201
|
-
valid_choices = set(choices)
|
|
202
|
-
|
|
203
|
-
def _auto_select(_: Buffer) -> None:
|
|
204
|
-
"""Auto-select text when a valid choice is entered."""
|
|
205
|
-
text = buffer.text
|
|
206
|
-
if not text or text not in valid_choices:
|
|
207
|
-
return
|
|
208
|
-
buffer.cursor_position = 0
|
|
209
|
-
buffer.start_selection(selection_type=SelectionType.CHARACTERS)
|
|
210
|
-
buffer.cursor_position = len(text)
|
|
211
|
-
|
|
212
|
-
handler_attached = False
|
|
213
|
-
try:
|
|
214
|
-
buffer.on_text_changed += _auto_select
|
|
215
|
-
handler_attached = True
|
|
216
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
217
|
-
logger.debug("Failed to attach auto-select handler: %s", exc)
|
|
218
|
-
|
|
219
|
-
try:
|
|
220
|
-
return session.prompt()
|
|
221
|
-
except (KeyboardInterrupt, EOFError):
|
|
222
|
-
return None
|
|
223
|
-
except Exception as exc: # pragma: no cover - defensive
|
|
224
|
-
logger.debug("PromptSession prompt failed (%s); falling back to basic prompt.", exc)
|
|
225
|
-
return _basic_prompt(message, completer)
|
|
226
|
-
finally:
|
|
227
|
-
if handler_attached:
|
|
228
|
-
try:
|
|
229
|
-
buffer.on_text_changed -= _auto_select
|
|
230
|
-
except Exception: # pragma: no cover - defensive
|
|
231
|
-
pass
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
class _FuzzyCompleter:
|
|
235
|
-
"""Fuzzy completer for prompt_toolkit."""
|
|
236
|
-
|
|
237
|
-
def __init__(self, words: list[str]) -> None:
|
|
238
|
-
"""Initialize fuzzy completer with word list.
|
|
239
|
-
|
|
240
|
-
Args:
|
|
241
|
-
words: List of words to complete from.
|
|
242
|
-
"""
|
|
243
|
-
self.words = words
|
|
244
|
-
|
|
245
|
-
def get_completions(self, document: Any, _complete_event: Any) -> Any: # pragma: no cover
|
|
246
|
-
"""Get fuzzy completions for the current word, ranked by score.
|
|
247
|
-
|
|
248
|
-
Args:
|
|
249
|
-
document: Document object from prompt_toolkit.
|
|
250
|
-
_complete_event: Completion event (unused).
|
|
251
|
-
|
|
252
|
-
Yields:
|
|
253
|
-
Completion objects matching the current word, in ranked order.
|
|
254
|
-
"""
|
|
255
|
-
# Get the entire buffer text (not just word before cursor)
|
|
256
|
-
buffer_text = document.text_before_cursor
|
|
257
|
-
if not buffer_text or not isinstance(buffer_text, str):
|
|
258
|
-
return
|
|
259
|
-
|
|
260
|
-
# Rank labels by fuzzy score
|
|
261
|
-
ranked_labels = _rank_labels(self.words, buffer_text)
|
|
262
|
-
|
|
263
|
-
# Yield ranked completions
|
|
264
|
-
for label in ranked_labels:
|
|
265
|
-
# Replace entire buffer text, not just the word before cursor
|
|
266
|
-
# This prevents concatenation issues with hyphenated names
|
|
267
|
-
yield Completion(label, start_position=-len(buffer_text))
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def _strip_spaces_for_matching(value: str) -> str:
|
|
271
|
-
"""Remove whitespace from a query for consistent fuzzy matching."""
|
|
272
|
-
return re.sub(r"\s+", "", value)
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
def _is_fuzzy_match(search: Any, target: Any) -> bool:
|
|
276
|
-
"""Case-insensitive fuzzy match with optional spaces; returns False for non-string inputs."""
|
|
277
|
-
# Ensure search is a string
|
|
278
|
-
if not isinstance(search, str) or not isinstance(target, str):
|
|
279
|
-
return False
|
|
280
|
-
|
|
281
|
-
if not search:
|
|
282
|
-
return True
|
|
283
|
-
|
|
284
|
-
# Strip spaces from search query - treat them as optional separators
|
|
285
|
-
# This allows "test agent" to match "test-agent", "test_agent", etc.
|
|
286
|
-
search_no_spaces = _strip_spaces_for_matching(search).lower()
|
|
287
|
-
if not search_no_spaces:
|
|
288
|
-
# If search is only spaces, match everything
|
|
289
|
-
return True
|
|
290
|
-
|
|
291
|
-
search_idx = 0
|
|
292
|
-
for char in target.lower():
|
|
293
|
-
if search_idx < len(search_no_spaces) and search_no_spaces[search_idx] == char:
|
|
294
|
-
search_idx += 1
|
|
295
|
-
if search_idx == len(search_no_spaces):
|
|
296
|
-
return True
|
|
297
|
-
return False
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
def _calculate_exact_match_bonus(search: str, target: str) -> int:
|
|
301
|
-
"""Calculate bonus for exact substring matches.
|
|
302
|
-
|
|
303
|
-
Spaces in search are treated as optional separators (stripped before matching).
|
|
304
|
-
"""
|
|
305
|
-
# Strip spaces from search - treat them as optional separators
|
|
306
|
-
search_no_spaces = _strip_spaces_for_matching(search).lower()
|
|
307
|
-
if not search_no_spaces:
|
|
308
|
-
return 0
|
|
309
|
-
return 100 if search_no_spaces in target.lower() else 0
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
def _calculate_consecutive_bonus(search: str, target: str) -> int:
|
|
313
|
-
"""Case-insensitive consecutive-character bonus."""
|
|
314
|
-
# Strip spaces from search - treat them as optional separators
|
|
315
|
-
search_no_spaces = _strip_spaces_for_matching(search).lower()
|
|
316
|
-
if not search_no_spaces:
|
|
317
|
-
return 0
|
|
318
|
-
|
|
319
|
-
consecutive = 0
|
|
320
|
-
max_consecutive = 0
|
|
321
|
-
search_idx = 0
|
|
322
|
-
|
|
323
|
-
for char in target.lower():
|
|
324
|
-
if search_idx < len(search_no_spaces) and search_no_spaces[search_idx] == char:
|
|
325
|
-
consecutive += 1
|
|
326
|
-
max_consecutive = max(max_consecutive, consecutive)
|
|
327
|
-
search_idx += 1
|
|
328
|
-
else:
|
|
329
|
-
consecutive = 0
|
|
330
|
-
|
|
331
|
-
return max_consecutive * 10
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def _calculate_length_bonus(search: str, target: str) -> int:
|
|
335
|
-
"""Calculate bonus for shorter search terms.
|
|
336
|
-
|
|
337
|
-
Spaces in search are treated as optional separators (stripped before calculation).
|
|
338
|
-
"""
|
|
339
|
-
# Strip spaces from search - treat them as optional separators
|
|
340
|
-
search_no_spaces = _strip_spaces_for_matching(search)
|
|
341
|
-
if not search_no_spaces:
|
|
342
|
-
return 0
|
|
343
|
-
return max(0, (len(target) - len(search_no_spaces)) * 2)
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def _fuzzy_score(search: Any, target: str) -> int:
|
|
347
|
-
"""Calculate fuzzy match score.
|
|
348
|
-
|
|
349
|
-
Higher score = better match.
|
|
350
|
-
Returns -1 if no match possible.
|
|
351
|
-
|
|
352
|
-
Args:
|
|
353
|
-
search: Search string (or any type - non-strings return -1)
|
|
354
|
-
target: Target string to match against
|
|
355
|
-
"""
|
|
356
|
-
# Ensure search is a string first
|
|
357
|
-
if not isinstance(search, str):
|
|
358
|
-
return -1
|
|
359
|
-
|
|
360
|
-
if not search:
|
|
361
|
-
return 0
|
|
362
|
-
|
|
363
|
-
if not _is_fuzzy_match(search, target):
|
|
364
|
-
return -1 # Not a fuzzy match
|
|
365
|
-
|
|
366
|
-
# Calculate score based on different factors
|
|
367
|
-
score = 0
|
|
368
|
-
score += _calculate_exact_match_bonus(search, target)
|
|
369
|
-
score += _calculate_consecutive_bonus(search, target)
|
|
370
|
-
score += _calculate_length_bonus(search, target)
|
|
371
|
-
|
|
372
|
-
return score
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def _extract_id_suffix(label: str) -> str:
|
|
376
|
-
"""Extract ID suffix from label for tie-breaking.
|
|
377
|
-
|
|
378
|
-
Args:
|
|
379
|
-
label: Display label (e.g., "name • [abc123...]")
|
|
380
|
-
|
|
381
|
-
Returns:
|
|
382
|
-
ID suffix string (e.g., "abc123") or empty string if not found
|
|
383
|
-
"""
|
|
384
|
-
# Look for pattern like "[abc123...]" or "[abc123]"
|
|
385
|
-
match = re.search(r"\[([^\]]+)\]", label)
|
|
386
|
-
return match.group(1) if match else ""
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
def _rank_labels(labels: list[str], query: Any) -> list[str]:
|
|
390
|
-
"""Rank labels by fuzzy score with deterministic tie-breaks.
|
|
391
|
-
|
|
392
|
-
Args:
|
|
393
|
-
labels: List of display labels to rank
|
|
394
|
-
query: Search query string (or any type - non-strings return sorted labels)
|
|
395
|
-
|
|
396
|
-
Returns:
|
|
397
|
-
Labels sorted by fuzzy score (descending), then case-insensitive label,
|
|
398
|
-
then id suffix for deterministic ordering.
|
|
399
|
-
"""
|
|
400
|
-
suffix_cache = {label: _extract_id_suffix(label) for label in labels}
|
|
401
|
-
|
|
402
|
-
if not query:
|
|
403
|
-
# No query: sort by case-insensitive label, then id suffix
|
|
404
|
-
return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
|
|
405
|
-
|
|
406
|
-
# Ensure query is a string
|
|
407
|
-
if not isinstance(query, str):
|
|
408
|
-
return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
|
|
409
|
-
|
|
410
|
-
query_lower = query.lower()
|
|
411
|
-
|
|
412
|
-
# Calculate scores and create tuples for sorting
|
|
413
|
-
scored_labels = []
|
|
414
|
-
for label in labels:
|
|
415
|
-
label_lower = label.lower()
|
|
416
|
-
score = _fuzzy_score(query_lower, label_lower)
|
|
417
|
-
if score >= 0: # Only include matches
|
|
418
|
-
scored_labels.append((score, label_lower, suffix_cache[label], label))
|
|
419
|
-
|
|
420
|
-
if not scored_labels:
|
|
421
|
-
# No fuzzy matches: fall back to deterministic label sorting
|
|
422
|
-
return sorted(labels, key=lambda lbl: (lbl.lower(), suffix_cache[lbl]))
|
|
423
|
-
|
|
424
|
-
# Sort by: score (desc), label (case-insensitive), id suffix, original label
|
|
425
|
-
scored_labels.sort(key=lambda x: (-x[0], x[1], x[2], x[3]))
|
|
426
|
-
|
|
427
|
-
return [label for _score, _label_lower, _id_suffix, label in scored_labels]
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def _perform_fuzzy_search(answer: str, labels: list[str], by_label: dict[str, dict[str, Any]]) -> dict[str, Any] | None:
|
|
431
|
-
"""Perform fuzzy search fallback and return best match.
|
|
432
|
-
|
|
433
|
-
Returns:
|
|
434
|
-
Selected resource dict or None if cancelled/no match.
|
|
435
|
-
"""
|
|
436
|
-
# Exact label match
|
|
437
|
-
if answer in by_label:
|
|
438
|
-
return by_label[answer]
|
|
439
|
-
|
|
440
|
-
# Fuzzy search fallback using ranked labels
|
|
441
|
-
# Check if query actually matches anything before ranking
|
|
442
|
-
query_lower = answer.lower()
|
|
443
|
-
has_match = False
|
|
444
|
-
for label in labels:
|
|
445
|
-
if _fuzzy_score(query_lower, label.lower()) >= 0:
|
|
446
|
-
has_match = True
|
|
447
|
-
break
|
|
448
|
-
|
|
449
|
-
if not has_match:
|
|
450
|
-
return None
|
|
451
|
-
|
|
452
|
-
ranked_labels = _rank_labels(labels, answer)
|
|
453
|
-
if ranked_labels:
|
|
454
|
-
# Return the top-ranked match
|
|
455
|
-
best_match = ranked_labels[0]
|
|
456
|
-
if best_match in by_label:
|
|
457
|
-
return by_label[best_match]
|
|
458
|
-
|
|
459
|
-
return None
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
def _check_fuzzy_pick_requirements() -> bool:
|
|
463
|
-
"""Check if fuzzy picking requirements are met."""
|
|
464
|
-
console = Console()
|
|
465
|
-
return _HAS_PTK and console.is_terminal and os.isatty(1)
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
def _extract_display_fields(row: dict[str, Any]) -> tuple[str, str, str, str]:
|
|
469
|
-
"""Extract display fields from row data."""
|
|
470
|
-
name = str(row.get("name", "")).strip()
|
|
471
|
-
_id = str(row.get("id", "")).strip()
|
|
472
|
-
type_ = str(row.get("type", "")).strip()
|
|
473
|
-
fw = str(row.get("framework", "")).strip()
|
|
474
|
-
return name, _id, type_, fw
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
def _build_primary_parts(name: str, type_: str, fw: str) -> list[str]:
|
|
478
|
-
"""Build primary display parts from name, type, and framework."""
|
|
479
|
-
parts = []
|
|
480
|
-
if name:
|
|
481
|
-
parts.append(name)
|
|
482
|
-
if type_:
|
|
483
|
-
parts.append(type_)
|
|
484
|
-
if fw:
|
|
485
|
-
parts.append(fw)
|
|
486
|
-
return parts
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
def _get_fallback_columns(columns: list[tuple]) -> list[tuple]:
|
|
490
|
-
"""Get first two visible columns for fallback display."""
|
|
491
|
-
return columns[:2]
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
def _is_standard_field(k: str) -> bool:
|
|
495
|
-
"""Check if field is a standard field to skip."""
|
|
496
|
-
return k in ("id", "name", "type", "framework")
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def _extract_fallback_values(row: dict[str, Any], columns: list[tuple]) -> list[str]:
|
|
500
|
-
"""Extract fallback values from columns."""
|
|
501
|
-
fallback_parts = []
|
|
502
|
-
for k, _hdr, _style, _w in columns:
|
|
503
|
-
if _is_standard_field(k):
|
|
504
|
-
continue
|
|
505
|
-
val = str(row.get(k, "")).strip()
|
|
506
|
-
if val:
|
|
507
|
-
fallback_parts.append(val)
|
|
508
|
-
if len(fallback_parts) >= 2:
|
|
509
|
-
break
|
|
510
|
-
return fallback_parts
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
def _build_display_parts(
|
|
514
|
-
name: str, _id: str, type_: str, fw: str, row: dict[str, Any], columns: list[tuple]
|
|
515
|
-
) -> list[str]:
|
|
516
|
-
"""Build complete display parts list."""
|
|
517
|
-
parts = _build_primary_parts(name, type_, fw)
|
|
518
|
-
|
|
519
|
-
if not parts:
|
|
520
|
-
# Use fallback columns
|
|
521
|
-
fallback_columns = _get_fallback_columns(columns)
|
|
522
|
-
parts.extend(_extract_fallback_values(row, fallback_columns))
|
|
523
|
-
|
|
524
|
-
if _id:
|
|
525
|
-
parts.append(f"[{_id}]")
|
|
526
|
-
|
|
527
|
-
return parts
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
def _row_display(row: dict[str, Any], columns: list[tuple]) -> str:
|
|
531
|
-
"""Build a compact text label for the palette.
|
|
532
|
-
|
|
533
|
-
Prefers: name • type • framework • [id] (when available)
|
|
534
|
-
Falls back to first 2 columns + [id].
|
|
535
|
-
"""
|
|
536
|
-
name, _id, type_, fw = _extract_display_fields(row)
|
|
537
|
-
parts = _build_display_parts(name, _id, type_, fw, row, columns)
|
|
538
|
-
return " • ".join(parts) if parts else (_id or "(row)")
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
def _build_unique_labels(
|
|
542
|
-
rows: list[dict[str, Any]], columns: list[tuple]
|
|
543
|
-
) -> tuple[list[str], dict[str, dict[str, Any]]]:
|
|
544
|
-
"""Build unique display labels and reverse mapping."""
|
|
545
|
-
labels = []
|
|
546
|
-
by_label: dict[str, dict[str, Any]] = {}
|
|
547
|
-
|
|
548
|
-
for r in rows:
|
|
549
|
-
label = _row_display(r, columns)
|
|
550
|
-
# Ensure uniqueness: if duplicate, suffix with …#n
|
|
551
|
-
if label in by_label:
|
|
552
|
-
i = 2
|
|
553
|
-
base = label
|
|
554
|
-
while f"{base} #{i}" in by_label:
|
|
555
|
-
i += 1
|
|
556
|
-
label = f"{base} #{i}"
|
|
557
|
-
labels.append(label)
|
|
558
|
-
by_label[label] = r
|
|
559
|
-
|
|
560
|
-
return labels, by_label
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
def _fuzzy_pick(
|
|
564
|
-
rows: list[dict[str, Any]], columns: list[tuple], title: str
|
|
565
|
-
) -> dict[str, Any] | None: # pragma: no cover - requires interactive prompt toolkit
|
|
566
|
-
"""Open a minimal fuzzy palette using prompt_toolkit.
|
|
567
|
-
|
|
568
|
-
Returns the selected row (dict) or None if cancelled/missing deps.
|
|
569
|
-
"""
|
|
570
|
-
if not _check_fuzzy_pick_requirements():
|
|
571
|
-
return None
|
|
572
|
-
|
|
573
|
-
# Build display labels and mapping
|
|
574
|
-
labels, by_label = _build_unique_labels(rows, columns)
|
|
575
|
-
|
|
576
|
-
# Create fuzzy completer
|
|
577
|
-
completer = _FuzzyCompleter(labels)
|
|
578
|
-
singular_title = title[:-1] if title.endswith("s") else title
|
|
579
|
-
answer = _prompt_with_auto_select(
|
|
580
|
-
f"Find {singular_title}: ",
|
|
581
|
-
completer,
|
|
582
|
-
labels,
|
|
583
|
-
)
|
|
584
|
-
if answer is None:
|
|
585
|
-
return None
|
|
586
|
-
|
|
587
|
-
return _perform_fuzzy_search(answer, labels, by_label) if answer else None
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
def _build_resource_labels(resources: list[Any]) -> tuple[list[str], dict[str, Any]]:
|
|
591
|
-
"""Build unique display labels for resources."""
|
|
592
|
-
labels = []
|
|
593
|
-
by_label: dict[str, Any] = {}
|
|
594
|
-
|
|
595
|
-
for resource in resources:
|
|
596
|
-
name = getattr(resource, "name", "Unknown")
|
|
597
|
-
_id = getattr(resource, "id", "Unknown")
|
|
598
|
-
|
|
599
|
-
# Create display label
|
|
600
|
-
label_parts = []
|
|
601
|
-
if name and name != "Unknown":
|
|
602
|
-
label_parts.append(name)
|
|
603
|
-
label_parts.append(f"[{_id[:8]}...]") # Show first 8 chars of ID
|
|
604
|
-
label = " • ".join(label_parts)
|
|
605
|
-
|
|
606
|
-
# Ensure uniqueness
|
|
607
|
-
if label in by_label:
|
|
608
|
-
i = 2
|
|
609
|
-
base = label
|
|
610
|
-
while f"{base} #{i}" in by_label:
|
|
611
|
-
i += 1
|
|
612
|
-
label = f"{base} #{i}"
|
|
613
|
-
|
|
614
|
-
labels.append(label)
|
|
615
|
-
by_label[label] = resource
|
|
616
|
-
|
|
617
|
-
return labels, by_label
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
def _fuzzy_pick_for_resources(
|
|
621
|
-
resources: list[Any], resource_type: str, _search_term: str
|
|
622
|
-
) -> Any | None: # pragma: no cover - interactive selection helper
|
|
623
|
-
"""Fuzzy picker for resource objects, similar to _fuzzy_pick but without column dependencies.
|
|
624
|
-
|
|
625
|
-
Args:
|
|
626
|
-
resources: List of resource objects to choose from
|
|
627
|
-
resource_type: Type of resource (e.g., "agent", "tool")
|
|
628
|
-
search_term: The search term that led to multiple matches
|
|
629
|
-
|
|
630
|
-
Returns:
|
|
631
|
-
Selected resource object or None if cancelled/no selection
|
|
632
|
-
"""
|
|
633
|
-
if not _check_fuzzy_pick_requirements():
|
|
634
|
-
return None
|
|
635
|
-
|
|
636
|
-
# Build labels and mapping
|
|
637
|
-
labels, by_label = _build_resource_labels(resources)
|
|
638
|
-
|
|
639
|
-
# Create fuzzy completer
|
|
640
|
-
completer = _FuzzyCompleter(labels)
|
|
641
|
-
answer = _prompt_with_auto_select(
|
|
642
|
-
f"Find {ICON_AGENT} {resource_type.title()}: ",
|
|
643
|
-
completer,
|
|
644
|
-
labels,
|
|
645
|
-
)
|
|
646
|
-
if answer is None:
|
|
647
|
-
return None
|
|
648
|
-
|
|
649
|
-
return _perform_fuzzy_search(answer, labels, by_label) if answer else None
|