glaip-sdk 0.1.3__py3-none-any.whl → 0.6.10__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 (141) hide show
  1. glaip_sdk/__init__.py +5 -2
  2. glaip_sdk/_version.py +9 -0
  3. glaip_sdk/agents/__init__.py +27 -0
  4. glaip_sdk/agents/base.py +1191 -0
  5. glaip_sdk/branding.py +13 -0
  6. glaip_sdk/cli/account_store.py +540 -0
  7. glaip_sdk/cli/auth.py +254 -15
  8. glaip_sdk/cli/commands/__init__.py +2 -2
  9. glaip_sdk/cli/commands/accounts.py +746 -0
  10. glaip_sdk/cli/commands/agents.py +213 -73
  11. glaip_sdk/cli/commands/common_config.py +101 -0
  12. glaip_sdk/cli/commands/configure.py +729 -113
  13. glaip_sdk/cli/commands/mcps.py +241 -72
  14. glaip_sdk/cli/commands/models.py +11 -5
  15. glaip_sdk/cli/commands/tools.py +49 -57
  16. glaip_sdk/cli/commands/transcripts.py +755 -0
  17. glaip_sdk/cli/config.py +48 -4
  18. glaip_sdk/cli/constants.py +38 -0
  19. glaip_sdk/cli/context.py +8 -0
  20. glaip_sdk/cli/core/__init__.py +79 -0
  21. glaip_sdk/cli/core/context.py +124 -0
  22. glaip_sdk/cli/core/output.py +846 -0
  23. glaip_sdk/cli/core/prompting.py +649 -0
  24. glaip_sdk/cli/core/rendering.py +187 -0
  25. glaip_sdk/cli/display.py +35 -19
  26. glaip_sdk/cli/hints.py +57 -0
  27. glaip_sdk/cli/io.py +6 -3
  28. glaip_sdk/cli/main.py +228 -119
  29. glaip_sdk/cli/masking.py +21 -33
  30. glaip_sdk/cli/pager.py +9 -10
  31. glaip_sdk/cli/parsers/__init__.py +1 -3
  32. glaip_sdk/cli/slash/__init__.py +0 -9
  33. glaip_sdk/cli/slash/accounts_controller.py +578 -0
  34. glaip_sdk/cli/slash/accounts_shared.py +75 -0
  35. glaip_sdk/cli/slash/agent_session.py +62 -21
  36. glaip_sdk/cli/slash/prompt.py +21 -0
  37. glaip_sdk/cli/slash/remote_runs_controller.py +566 -0
  38. glaip_sdk/cli/slash/session.py +771 -140
  39. glaip_sdk/cli/slash/tui/__init__.py +9 -0
  40. glaip_sdk/cli/slash/tui/accounts.tcss +86 -0
  41. glaip_sdk/cli/slash/tui/accounts_app.py +876 -0
  42. glaip_sdk/cli/slash/tui/background_tasks.py +72 -0
  43. glaip_sdk/cli/slash/tui/loading.py +58 -0
  44. glaip_sdk/cli/slash/tui/remote_runs_app.py +628 -0
  45. glaip_sdk/cli/transcript/__init__.py +12 -52
  46. glaip_sdk/cli/transcript/cache.py +255 -44
  47. glaip_sdk/cli/transcript/capture.py +27 -1
  48. glaip_sdk/cli/transcript/history.py +815 -0
  49. glaip_sdk/cli/transcript/viewer.py +72 -499
  50. glaip_sdk/cli/update_notifier.py +14 -5
  51. glaip_sdk/cli/utils.py +243 -1252
  52. glaip_sdk/cli/validators.py +5 -6
  53. glaip_sdk/client/__init__.py +2 -1
  54. glaip_sdk/client/_agent_payloads.py +45 -9
  55. glaip_sdk/client/agent_runs.py +147 -0
  56. glaip_sdk/client/agents.py +287 -29
  57. glaip_sdk/client/base.py +1 -0
  58. glaip_sdk/client/main.py +19 -10
  59. glaip_sdk/client/mcps.py +122 -12
  60. glaip_sdk/client/run_rendering.py +133 -88
  61. glaip_sdk/client/shared.py +21 -0
  62. glaip_sdk/client/tools.py +155 -10
  63. glaip_sdk/config/constants.py +11 -0
  64. glaip_sdk/mcps/__init__.py +21 -0
  65. glaip_sdk/mcps/base.py +345 -0
  66. glaip_sdk/models/__init__.py +90 -0
  67. glaip_sdk/models/agent.py +47 -0
  68. glaip_sdk/models/agent_runs.py +116 -0
  69. glaip_sdk/models/common.py +42 -0
  70. glaip_sdk/models/mcp.py +33 -0
  71. glaip_sdk/models/tool.py +33 -0
  72. glaip_sdk/payload_schemas/__init__.py +1 -13
  73. glaip_sdk/registry/__init__.py +55 -0
  74. glaip_sdk/registry/agent.py +164 -0
  75. glaip_sdk/registry/base.py +139 -0
  76. glaip_sdk/registry/mcp.py +253 -0
  77. glaip_sdk/registry/tool.py +232 -0
  78. glaip_sdk/rich_components.py +58 -2
  79. glaip_sdk/runner/__init__.py +59 -0
  80. glaip_sdk/runner/base.py +84 -0
  81. glaip_sdk/runner/deps.py +115 -0
  82. glaip_sdk/runner/langgraph.py +706 -0
  83. glaip_sdk/runner/mcp_adapter/__init__.py +13 -0
  84. glaip_sdk/runner/mcp_adapter/base_mcp_adapter.py +43 -0
  85. glaip_sdk/runner/mcp_adapter/langchain_mcp_adapter.py +257 -0
  86. glaip_sdk/runner/mcp_adapter/mcp_config_builder.py +95 -0
  87. glaip_sdk/runner/tool_adapter/__init__.py +18 -0
  88. glaip_sdk/runner/tool_adapter/base_tool_adapter.py +44 -0
  89. glaip_sdk/runner/tool_adapter/langchain_tool_adapter.py +219 -0
  90. glaip_sdk/tools/__init__.py +22 -0
  91. glaip_sdk/tools/base.py +435 -0
  92. glaip_sdk/utils/__init__.py +58 -12
  93. glaip_sdk/utils/a2a/__init__.py +34 -0
  94. glaip_sdk/utils/a2a/event_processor.py +188 -0
  95. glaip_sdk/utils/bundler.py +267 -0
  96. glaip_sdk/utils/client.py +111 -0
  97. glaip_sdk/utils/client_utils.py +39 -7
  98. glaip_sdk/utils/datetime_helpers.py +58 -0
  99. glaip_sdk/utils/discovery.py +78 -0
  100. glaip_sdk/utils/display.py +23 -15
  101. glaip_sdk/utils/export.py +143 -0
  102. glaip_sdk/utils/general.py +0 -33
  103. glaip_sdk/utils/import_export.py +12 -7
  104. glaip_sdk/utils/import_resolver.py +492 -0
  105. glaip_sdk/utils/instructions.py +101 -0
  106. glaip_sdk/utils/rendering/__init__.py +115 -1
  107. glaip_sdk/utils/rendering/formatting.py +5 -30
  108. glaip_sdk/utils/rendering/layout/__init__.py +64 -0
  109. glaip_sdk/utils/rendering/{renderer → layout}/panels.py +9 -0
  110. glaip_sdk/utils/rendering/{renderer → layout}/progress.py +70 -1
  111. glaip_sdk/utils/rendering/layout/summary.py +74 -0
  112. glaip_sdk/utils/rendering/layout/transcript.py +606 -0
  113. glaip_sdk/utils/rendering/models.py +1 -0
  114. glaip_sdk/utils/rendering/renderer/__init__.py +9 -47
  115. glaip_sdk/utils/rendering/renderer/base.py +217 -1476
  116. glaip_sdk/utils/rendering/renderer/debug.py +26 -20
  117. glaip_sdk/utils/rendering/renderer/factory.py +138 -0
  118. glaip_sdk/utils/rendering/renderer/stream.py +4 -12
  119. glaip_sdk/utils/rendering/renderer/thinking.py +273 -0
  120. glaip_sdk/utils/rendering/renderer/tool_panels.py +442 -0
  121. glaip_sdk/utils/rendering/renderer/transcript_mode.py +162 -0
  122. glaip_sdk/utils/rendering/state.py +204 -0
  123. glaip_sdk/utils/rendering/steps/__init__.py +34 -0
  124. glaip_sdk/utils/rendering/{steps.py → steps/event_processor.py} +53 -440
  125. glaip_sdk/utils/rendering/steps/format.py +176 -0
  126. glaip_sdk/utils/rendering/steps/manager.py +387 -0
  127. glaip_sdk/utils/rendering/timing.py +36 -0
  128. glaip_sdk/utils/rendering/viewer/__init__.py +21 -0
  129. glaip_sdk/utils/rendering/viewer/presenter.py +184 -0
  130. glaip_sdk/utils/resource_refs.py +25 -13
  131. glaip_sdk/utils/runtime_config.py +425 -0
  132. glaip_sdk/utils/serialization.py +18 -0
  133. glaip_sdk/utils/sync.py +142 -0
  134. glaip_sdk/utils/tool_detection.py +33 -0
  135. glaip_sdk/utils/validation.py +16 -24
  136. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/METADATA +42 -4
  137. glaip_sdk-0.6.10.dist-info/RECORD +159 -0
  138. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/WHEEL +1 -1
  139. glaip_sdk/models.py +0 -240
  140. glaip_sdk-0.1.3.dist-info/RECORD +0 -83
  141. {glaip_sdk-0.1.3.dist-info → glaip_sdk-0.6.10.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,649 @@
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