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.
Files changed (156) hide show
  1. glaip_sdk/__init__.py +42 -5
  2. {glaip_sdk-0.6.12.dist-info → glaip_sdk-0.6.15.dist-info}/METADATA +32 -37
  3. glaip_sdk-0.6.15.dist-info/RECORD +12 -0
  4. {glaip_sdk-0.6.12.dist-info → glaip_sdk-0.6.15.dist-info}/WHEEL +2 -1
  5. glaip_sdk-0.6.15.dist-info/entry_points.txt +2 -0
  6. glaip_sdk-0.6.15.dist-info/top_level.txt +1 -0
  7. glaip_sdk/agents/__init__.py +0 -27
  8. glaip_sdk/agents/base.py +0 -1191
  9. glaip_sdk/cli/__init__.py +0 -9
  10. glaip_sdk/cli/account_store.py +0 -540
  11. glaip_sdk/cli/agent_config.py +0 -78
  12. glaip_sdk/cli/auth.py +0 -699
  13. glaip_sdk/cli/commands/__init__.py +0 -5
  14. glaip_sdk/cli/commands/accounts.py +0 -746
  15. glaip_sdk/cli/commands/agents.py +0 -1509
  16. glaip_sdk/cli/commands/common_config.py +0 -101
  17. glaip_sdk/cli/commands/configure.py +0 -896
  18. glaip_sdk/cli/commands/mcps.py +0 -1356
  19. glaip_sdk/cli/commands/models.py +0 -69
  20. glaip_sdk/cli/commands/tools.py +0 -576
  21. glaip_sdk/cli/commands/transcripts.py +0 -755
  22. glaip_sdk/cli/commands/update.py +0 -61
  23. glaip_sdk/cli/config.py +0 -95
  24. glaip_sdk/cli/constants.py +0 -38
  25. glaip_sdk/cli/context.py +0 -150
  26. glaip_sdk/cli/core/__init__.py +0 -79
  27. glaip_sdk/cli/core/context.py +0 -124
  28. glaip_sdk/cli/core/output.py +0 -846
  29. glaip_sdk/cli/core/prompting.py +0 -649
  30. glaip_sdk/cli/core/rendering.py +0 -187
  31. glaip_sdk/cli/display.py +0 -355
  32. glaip_sdk/cli/hints.py +0 -57
  33. glaip_sdk/cli/io.py +0 -112
  34. glaip_sdk/cli/main.py +0 -604
  35. glaip_sdk/cli/masking.py +0 -136
  36. glaip_sdk/cli/mcp_validators.py +0 -287
  37. glaip_sdk/cli/pager.py +0 -266
  38. glaip_sdk/cli/parsers/__init__.py +0 -7
  39. glaip_sdk/cli/parsers/json_input.py +0 -177
  40. glaip_sdk/cli/resolution.py +0 -67
  41. glaip_sdk/cli/rich_helpers.py +0 -27
  42. glaip_sdk/cli/slash/__init__.py +0 -15
  43. glaip_sdk/cli/slash/accounts_controller.py +0 -578
  44. glaip_sdk/cli/slash/accounts_shared.py +0 -75
  45. glaip_sdk/cli/slash/agent_session.py +0 -285
  46. glaip_sdk/cli/slash/prompt.py +0 -256
  47. glaip_sdk/cli/slash/remote_runs_controller.py +0 -566
  48. glaip_sdk/cli/slash/session.py +0 -1708
  49. glaip_sdk/cli/slash/tui/__init__.py +0 -9
  50. glaip_sdk/cli/slash/tui/accounts_app.py +0 -876
  51. glaip_sdk/cli/slash/tui/background_tasks.py +0 -72
  52. glaip_sdk/cli/slash/tui/loading.py +0 -58
  53. glaip_sdk/cli/slash/tui/remote_runs_app.py +0 -628
  54. glaip_sdk/cli/transcript/__init__.py +0 -31
  55. glaip_sdk/cli/transcript/cache.py +0 -536
  56. glaip_sdk/cli/transcript/capture.py +0 -329
  57. glaip_sdk/cli/transcript/export.py +0 -38
  58. glaip_sdk/cli/transcript/history.py +0 -815
  59. glaip_sdk/cli/transcript/launcher.py +0 -77
  60. glaip_sdk/cli/transcript/viewer.py +0 -374
  61. glaip_sdk/cli/update_notifier.py +0 -290
  62. glaip_sdk/cli/utils.py +0 -263
  63. glaip_sdk/cli/validators.py +0 -238
  64. glaip_sdk/client/__init__.py +0 -11
  65. glaip_sdk/client/_agent_payloads.py +0 -520
  66. glaip_sdk/client/agent_runs.py +0 -147
  67. glaip_sdk/client/agents.py +0 -1335
  68. glaip_sdk/client/base.py +0 -502
  69. glaip_sdk/client/main.py +0 -249
  70. glaip_sdk/client/mcps.py +0 -370
  71. glaip_sdk/client/run_rendering.py +0 -700
  72. glaip_sdk/client/shared.py +0 -21
  73. glaip_sdk/client/tools.py +0 -661
  74. glaip_sdk/client/validators.py +0 -198
  75. glaip_sdk/config/constants.py +0 -52
  76. glaip_sdk/mcps/__init__.py +0 -21
  77. glaip_sdk/mcps/base.py +0 -345
  78. glaip_sdk/models/__init__.py +0 -90
  79. glaip_sdk/models/agent.py +0 -47
  80. glaip_sdk/models/agent_runs.py +0 -116
  81. glaip_sdk/models/common.py +0 -42
  82. glaip_sdk/models/mcp.py +0 -33
  83. glaip_sdk/models/tool.py +0 -33
  84. glaip_sdk/payload_schemas/__init__.py +0 -7
  85. glaip_sdk/payload_schemas/agent.py +0 -85
  86. glaip_sdk/registry/__init__.py +0 -55
  87. glaip_sdk/registry/agent.py +0 -164
  88. glaip_sdk/registry/base.py +0 -139
  89. glaip_sdk/registry/mcp.py +0 -253
  90. glaip_sdk/registry/tool.py +0 -232
  91. glaip_sdk/runner/__init__.py +0 -59
  92. glaip_sdk/runner/base.py +0 -84
  93. glaip_sdk/runner/deps.py +0 -115
  94. glaip_sdk/runner/langgraph.py +0 -782
  95. glaip_sdk/runner/mcp_adapter/__init__.py +0 -13
  96. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +0 -43
  97. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +0 -257
  98. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +0 -95
  99. glaip_sdk/runner/tool_adapter/__init__.py +0 -18
  100. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +0 -44
  101. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +0 -219
  102. glaip_sdk/tools/__init__.py +0 -22
  103. glaip_sdk/tools/base.py +0 -435
  104. glaip_sdk/utils/__init__.py +0 -86
  105. glaip_sdk/utils/a2a/__init__.py +0 -34
  106. glaip_sdk/utils/a2a/event_processor.py +0 -188
  107. glaip_sdk/utils/agent_config.py +0 -194
  108. glaip_sdk/utils/bundler.py +0 -267
  109. glaip_sdk/utils/client.py +0 -111
  110. glaip_sdk/utils/client_utils.py +0 -486
  111. glaip_sdk/utils/datetime_helpers.py +0 -58
  112. glaip_sdk/utils/discovery.py +0 -78
  113. glaip_sdk/utils/display.py +0 -135
  114. glaip_sdk/utils/export.py +0 -143
  115. glaip_sdk/utils/general.py +0 -61
  116. glaip_sdk/utils/import_export.py +0 -168
  117. glaip_sdk/utils/import_resolver.py +0 -492
  118. glaip_sdk/utils/instructions.py +0 -101
  119. glaip_sdk/utils/rendering/__init__.py +0 -115
  120. glaip_sdk/utils/rendering/formatting.py +0 -264
  121. glaip_sdk/utils/rendering/layout/__init__.py +0 -64
  122. glaip_sdk/utils/rendering/layout/panels.py +0 -156
  123. glaip_sdk/utils/rendering/layout/progress.py +0 -202
  124. glaip_sdk/utils/rendering/layout/summary.py +0 -74
  125. glaip_sdk/utils/rendering/layout/transcript.py +0 -606
  126. glaip_sdk/utils/rendering/models.py +0 -85
  127. glaip_sdk/utils/rendering/renderer/__init__.py +0 -55
  128. glaip_sdk/utils/rendering/renderer/base.py +0 -1024
  129. glaip_sdk/utils/rendering/renderer/config.py +0 -27
  130. glaip_sdk/utils/rendering/renderer/console.py +0 -55
  131. glaip_sdk/utils/rendering/renderer/debug.py +0 -178
  132. glaip_sdk/utils/rendering/renderer/factory.py +0 -138
  133. glaip_sdk/utils/rendering/renderer/stream.py +0 -202
  134. glaip_sdk/utils/rendering/renderer/summary_window.py +0 -79
  135. glaip_sdk/utils/rendering/renderer/thinking.py +0 -273
  136. glaip_sdk/utils/rendering/renderer/toggle.py +0 -182
  137. glaip_sdk/utils/rendering/renderer/tool_panels.py +0 -442
  138. glaip_sdk/utils/rendering/renderer/transcript_mode.py +0 -162
  139. glaip_sdk/utils/rendering/state.py +0 -204
  140. glaip_sdk/utils/rendering/step_tree_state.py +0 -100
  141. glaip_sdk/utils/rendering/steps/__init__.py +0 -34
  142. glaip_sdk/utils/rendering/steps/event_processor.py +0 -778
  143. glaip_sdk/utils/rendering/steps/format.py +0 -176
  144. glaip_sdk/utils/rendering/steps/manager.py +0 -387
  145. glaip_sdk/utils/rendering/timing.py +0 -36
  146. glaip_sdk/utils/rendering/viewer/__init__.py +0 -21
  147. glaip_sdk/utils/rendering/viewer/presenter.py +0 -184
  148. glaip_sdk/utils/resource_refs.py +0 -195
  149. glaip_sdk/utils/run_renderer.py +0 -41
  150. glaip_sdk/utils/runtime_config.py +0 -425
  151. glaip_sdk/utils/serialization.py +0 -424
  152. glaip_sdk/utils/sync.py +0 -142
  153. glaip_sdk/utils/tool_detection.py +0 -33
  154. glaip_sdk/utils/validation.py +0 -264
  155. glaip_sdk-0.6.12.dist-info/RECORD +0 -159
  156. glaip_sdk-0.6.12.dist-info/entry_points.txt +0 -3
@@ -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