comfygit 0.3.1__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.
@@ -0,0 +1,843 @@
1
+ """Interactive resolution strategies for CLI."""
2
+
3
+
4
+ from comfygit_cli.utils.civitai_errors import show_civitai_auth_help
5
+ from comfygit_cli.utils.progress import create_progress_callback, show_download_stats
6
+ from comfygit_core.models.protocols import (
7
+ ModelResolutionStrategy,
8
+ NodeResolutionStrategy,
9
+ )
10
+ from comfygit_core.models.shared import ModelWithLocation
11
+ from comfygit_core.models.workflow import (
12
+ ModelResolutionContext,
13
+ NodeResolutionContext,
14
+ ResolvedModel,
15
+ ResolvedNodePackage,
16
+ ScoredMatch,
17
+ WorkflowNodeWidgetRef,
18
+ )
19
+
20
+
21
+ class InteractiveNodeStrategy(NodeResolutionStrategy):
22
+ """Interactive node resolution with unified search."""
23
+
24
+ def __init__(self) -> None:
25
+ """Initialize strategy (stateless - context passed per resolution)."""
26
+ self._last_choice: str | None = None # Track last user choice for optional detection
27
+
28
+ def _unified_choice_prompt(self, prompt_text: str, num_options: int, has_browse: bool = False) -> str:
29
+ """Unified choice prompt with inline manual/skip/optional/refine options.
30
+
31
+ Args:
32
+ prompt_text: The choice prompt like "Choice [1]/r/m/o/s: "
33
+ num_options: Number of valid numeric options (1-based)
34
+ has_browse: Whether option 0 (browse) is available
35
+
36
+ Returns:
37
+ User's choice as string (number, 'm', 'o', 's', 'r', or '0' for browse)
38
+ """
39
+ while True:
40
+ choice = input(prompt_text).strip().lower()
41
+
42
+ # Default to '1' if empty
43
+ if not choice:
44
+ return "1"
45
+
46
+ # Check special options
47
+ if choice in ('m', 's', 'o', 'r'):
48
+ return choice
49
+
50
+ # Check browse option
51
+ if has_browse and choice == '0':
52
+ return '0'
53
+
54
+ # Check numeric range
55
+ if choice.isdigit():
56
+ idx = int(choice)
57
+ if 1 <= idx <= num_options:
58
+ return choice
59
+
60
+ print(" Invalid choice, try again")
61
+
62
+ def _get_manual_package_id(self, node_type: str) -> ResolvedNodePackage | None:
63
+ """Get package ID from manual user input."""
64
+ pkg_id = input("Enter package ID: ").strip()
65
+ if not pkg_id:
66
+ return None
67
+
68
+ print(f" Note: Package '{pkg_id}' will be verified during install")
69
+ return ResolvedNodePackage(
70
+ node_type=node_type,
71
+ match_type="manual",
72
+ package_id=pkg_id
73
+ )
74
+
75
+ def _get_optional_package(self, node_type: str) -> ResolvedNodePackage:
76
+ """Get package ID from manual user input."""
77
+ return ResolvedNodePackage(
78
+ node_type=node_type,
79
+ match_type="optional"
80
+ )
81
+
82
+ def resolve_unknown_node(
83
+ self,
84
+ node_type: str,
85
+ possible: list[ResolvedNodePackage],
86
+ context: "NodeResolutionContext"
87
+ ) -> ResolvedNodePackage | None:
88
+ """Prompt user to resolve unknown node.
89
+
90
+ Args:
91
+ node_type: The unknown node type
92
+ possible: List of possible package matches
93
+ context: Resolution context with search function and installed packages
94
+
95
+ Returns:
96
+ ResolvedNodePackage or None to skip
97
+ """
98
+ # Case 1: Ambiguous from global table (multiple matches)
99
+ if possible and len(possible) > 1:
100
+ return self._resolve_ambiguous(node_type, possible, context)
101
+
102
+ # Case 2: Single match from global table - confirm
103
+ # NOTE: Shouldn't be called since automatic resolution should handle this
104
+ if len(possible) == 1:
105
+ pkg = possible[0]
106
+ print(f"\n✓ Found in registry: {pkg.package_id}")
107
+ print(f" For node: {node_type}")
108
+
109
+ choice = input("Accept? [Y/n]: ").strip().lower()
110
+ if choice in ('', 'y', 'yes'):
111
+ return pkg
112
+ # User rejected - fall through to search
113
+
114
+ # Case 3: No matches or user rejected single match - use unified search with refinement
115
+ print(f"\n⚠️ Node not found in registry: {node_type}")
116
+
117
+ if not context.search_fn:
118
+ # No search available - can only mark optional, manual, or skip
119
+ print("\n [m] - Manually enter package ID")
120
+ print(" [o] - Mark as optional (workflow works without it)")
121
+ print(" [s] - Skip (leave unresolved)")
122
+ choice = self._unified_choice_prompt("Choice [m]/o/s: ", num_options=0, has_browse=False)
123
+
124
+ if choice == 'm':
125
+ self._last_choice = 'manual'
126
+ return self._get_manual_package_id(node_type)
127
+ elif choice == 'o':
128
+ self._last_choice = 'optional'
129
+ print(f" ✓ Marked '{node_type}' as optional")
130
+ return self._get_optional_package(node_type)
131
+ self._last_choice = 'skip'
132
+ return None
133
+
134
+ # Search with refinement loop
135
+ search_term = node_type
136
+
137
+ while True:
138
+ print(f"🔍 Searching for: {search_term}")
139
+
140
+ results = context.search_fn(
141
+ node_type=search_term,
142
+ installed_packages=context.installed_packages,
143
+ include_registry=True,
144
+ limit=5
145
+ )
146
+
147
+ if results:
148
+ # Show results with refinement option
149
+ result = self._show_search_results_with_refinement(
150
+ node_type, search_term, results, context
151
+ )
152
+
153
+ if result == "REFINE":
154
+ # User wants to refine search
155
+ new_term = input("\nEnter new search term: ").strip()
156
+ if new_term:
157
+ search_term = new_term
158
+ continue
159
+ else:
160
+ print(" Keeping previous search term")
161
+ continue
162
+ else:
163
+ # User made a choice (package, optional, manual, or skip)
164
+ # Type narrowing: result is ResolvedNodePackage | None
165
+ assert result is None or isinstance(result, ResolvedNodePackage)
166
+ return result
167
+ else:
168
+ # No results found
169
+ print(" No packages found")
170
+ print("\n [r] - Refine search")
171
+ print(" [m] - Manually enter package ID")
172
+ print(" [o] - Mark as optional (workflow works without it)")
173
+ print(" [s] - Skip (leave unresolved)")
174
+ choice = self._unified_choice_prompt("Choice [r]/m/o/s: ", num_options=0, has_browse=False)
175
+
176
+ if choice == 'r':
177
+ new_term = input("\nEnter new search term: ").strip()
178
+ if new_term:
179
+ search_term = new_term
180
+ continue
181
+ elif choice == 'm':
182
+ self._last_choice = 'manual'
183
+ return self._get_manual_package_id(node_type)
184
+ elif choice == 'o':
185
+ self._last_choice = 'optional'
186
+ print(f" ✓ Marked '{node_type}' as optional")
187
+ return self._get_optional_package(node_type)
188
+ self._last_choice = 'skip'
189
+ return None
190
+
191
+ def _resolve_ambiguous(
192
+ self,
193
+ node_type: str,
194
+ possible: list[ResolvedNodePackage],
195
+ context: "NodeResolutionContext"
196
+ ) -> ResolvedNodePackage | None:
197
+ """Handle ambiguous matches from global table."""
198
+ print(f"\n🔍 Found {len(possible)} matches for '{node_type}':")
199
+ display_count = min(5, len(possible))
200
+ for i, pkg in enumerate(possible[:display_count], 1):
201
+ display_name = pkg.package_data.display_name if pkg.package_data else pkg.package_id
202
+ desc = pkg.package_data.description if pkg.package_data else "No description"
203
+ print(f" {i}. {display_name or pkg.package_id}")
204
+ if desc and len(desc) > 60:
205
+ desc = desc[:57] + "..."
206
+ print(f" {desc}")
207
+
208
+ has_browse = len(possible) > 5
209
+ if has_browse:
210
+ print(f" 0. Browse all {len(possible)} matches")
211
+
212
+ print("\n [1-9] - Select package to install")
213
+ print(" [o] - Mark as optional (workflow works without it)")
214
+ print(" [m] - Manually enter package ID")
215
+ print(" [s] - Skip (leave unresolved)")
216
+
217
+ choice = self._unified_choice_prompt(
218
+ "Choice [1]/o/m/s: ",
219
+ num_options=display_count,
220
+ has_browse=has_browse
221
+ )
222
+
223
+ if choice == 's':
224
+ self._last_choice = 'skip'
225
+ return None
226
+ elif choice == 'o':
227
+ # Return None to skip - caller will check _last_choice for optional
228
+ self._last_choice = 'optional'
229
+ print(f" ✓ Marked '{node_type}' as optional")
230
+ return self._get_optional_package(node_type)
231
+ elif choice == 'm':
232
+ self._last_choice = 'manual'
233
+ return self._get_manual_package_id(node_type)
234
+ elif choice == '0':
235
+ self._last_choice = 'browse'
236
+ selected = self._browse_all_packages(possible, context)
237
+ if selected == "BACK":
238
+ return None
239
+ elif isinstance(selected, ResolvedNodePackage):
240
+ return self._create_resolved_from_match(node_type, selected)
241
+ return None
242
+ else:
243
+ self._last_choice = 'select'
244
+ idx = int(choice) - 1
245
+ selected = possible[idx]
246
+ # Update match_type to ensure it's saved to node_mappings
247
+ # User selected from ambiguous list, so this counts as user confirmation
248
+ return self._create_resolved_from_match(node_type, selected)
249
+
250
+ def _show_search_results_with_refinement(
251
+ self,
252
+ node_type: str,
253
+ search_term: str,
254
+ results: list,
255
+ context: "NodeResolutionContext"
256
+ ) -> ResolvedNodePackage | None | str:
257
+ """Show search results with refinement option.
258
+
259
+ Returns:
260
+ ResolvedNodePackage - user selected a package
261
+ "REFINE" - user wants to refine search
262
+ None - user skipped or marked optional
263
+ """
264
+ print(f"\nFound {len(results)} potential matches:\n")
265
+
266
+ display_count = min(5, len(results))
267
+ for i, match in enumerate(results[:display_count], 1):
268
+ pkg_id = match.package_id
269
+ desc = (match.package_data.description or "No description")[:60] if match.package_data else ""
270
+ installed_marker = " (installed)" if pkg_id in context.installed_packages else ""
271
+
272
+ print(f" {i}. {pkg_id}{installed_marker}")
273
+ if desc:
274
+ print(f" {desc}")
275
+ print()
276
+
277
+ print(" [1-5] - Select package to install")
278
+ print(" [r] - Refine search")
279
+ print(" [m] - Manually enter package ID")
280
+ print(" [o] - Mark as optional (workflow works without it)")
281
+ print(" [s] - Skip (leave unresolved)")
282
+
283
+ choice = self._unified_choice_prompt(
284
+ "Choice [1]/r/m/o/s: ",
285
+ num_options=display_count,
286
+ has_browse=False
287
+ )
288
+
289
+ if choice == 's':
290
+ self._last_choice = 'skip'
291
+ return None
292
+ elif choice == 'r':
293
+ self._last_choice = 'refine'
294
+ return "REFINE"
295
+ elif choice == 'o':
296
+ self._last_choice = 'optional'
297
+ print(f" ✓ Marked '{node_type}' as optional")
298
+ return self._get_optional_package(node_type)
299
+ elif choice == 'm':
300
+ self._last_choice = 'manual'
301
+ return self._get_manual_package_id(node_type)
302
+ else:
303
+ self._last_choice = 'select'
304
+ idx = int(choice) - 1
305
+ selected = results[idx]
306
+ print(f"\n✓ Selected: {selected.package_id}")
307
+ return self._create_resolved_from_match(node_type, selected)
308
+
309
+ def _create_resolved_from_match(
310
+ self,
311
+ node_type: str,
312
+ match: ResolvedNodePackage
313
+ ) -> ResolvedNodePackage:
314
+ """Create ResolvedNodePackage from user-confirmed match.
315
+
316
+ Args:
317
+ node_type: The node type being resolved
318
+ match: ResolvedNodePackage to update with user confirmation
319
+
320
+ Returns:
321
+ ResolvedNodePackage with match_type="user_confirmed"
322
+ """
323
+ # Use existing confidence or default to 1.0
324
+ confidence: float = getattr(match, 'match_confidence', None) or 1.0
325
+ versions = getattr(match, 'versions', [])
326
+
327
+ return ResolvedNodePackage(
328
+ package_id=match.package_id,
329
+ package_data=match.package_data,
330
+ node_type=node_type,
331
+ versions=versions,
332
+ match_type="user_confirmed",
333
+ match_confidence=confidence
334
+ )
335
+
336
+ def _browse_all_packages(
337
+ self,
338
+ results: list[ResolvedNodePackage],
339
+ context: "NodeResolutionContext"
340
+ ) -> ResolvedNodePackage | str | None:
341
+ """Browse all matches with pagination.
342
+
343
+ Returns:
344
+ ResolvedNodePackage if user selects a package
345
+ "BACK" if user goes back
346
+ None if user quits
347
+ """
348
+ page = 0
349
+ page_size = 10
350
+ total_pages = (len(results) + page_size - 1) // page_size
351
+
352
+ while True:
353
+ start = page * page_size
354
+ end = min(start + page_size, len(results))
355
+
356
+ print(f"\nAll matches (Page {page + 1}/{total_pages}):\n")
357
+
358
+ for i, match in enumerate(results[start:end], start + 1):
359
+ pkg_id = match.package_id
360
+ installed_marker = " (installed)" if pkg_id in context.installed_packages else ""
361
+ print(f" {i}. {pkg_id}{installed_marker}")
362
+
363
+ print("\n[N]ext, [P]rev, number, [B]ack, or [Q]uit:")
364
+
365
+ choice = input("Choice: ").strip().lower()
366
+
367
+ if choice == 'n':
368
+ if page < total_pages - 1:
369
+ page += 1
370
+ else:
371
+ print(" Already on last page")
372
+ elif choice == 'p':
373
+ if page > 0:
374
+ page -= 1
375
+ else:
376
+ print(" Already on first page")
377
+ elif choice == 'b':
378
+ return "BACK"
379
+ elif choice == 'q':
380
+ return None
381
+ elif choice.isdigit():
382
+ idx = int(choice) - 1
383
+ if 0 <= idx < len(results):
384
+ return results[idx]
385
+ else:
386
+ print(" Invalid number")
387
+ else:
388
+ print(" Invalid choice")
389
+
390
+
391
+ def confirm_node_install(self, package: ResolvedNodePackage) -> bool:
392
+ """Always confirm since user already made the choice."""
393
+ return True
394
+
395
+
396
+ class InteractiveModelStrategy(ModelResolutionStrategy):
397
+ """Interactive model resolution with user prompts."""
398
+
399
+ def _unified_choice_prompt(self, prompt_text: str, num_options: int, has_browse: bool = False) -> str:
400
+ """Unified choice prompt with inline refine/skip/optional/download options.
401
+
402
+ Args:
403
+ prompt_text: The choice prompt like "Choice [1]/r/o/s: "
404
+ num_options: Number of valid numeric options (1-based)
405
+ has_browse: Whether option 0 (browse) is available
406
+
407
+ Returns:
408
+ User's choice as string (number, 'r', 'o', 's', 'd', or '0' for browse)
409
+ """
410
+ while True:
411
+ choice = input(prompt_text).strip().lower()
412
+
413
+ # Default to '1' if empty
414
+ if not choice:
415
+ return "1"
416
+
417
+ # Check special options
418
+ if choice in ('r', 's', 'o', 'd'):
419
+ return choice
420
+
421
+ # Check browse option
422
+ if has_browse and choice == '0':
423
+ return '0'
424
+
425
+ # Check numeric range
426
+ if choice.isdigit():
427
+ idx = int(choice)
428
+ if 1 <= idx <= num_options:
429
+ return choice
430
+
431
+ print(" Invalid choice, try again")
432
+
433
+ def resolve_model(
434
+ self,
435
+ reference: WorkflowNodeWidgetRef,
436
+ candidates: list[ResolvedModel],
437
+ context: ModelResolutionContext
438
+ ) -> ResolvedModel | None:
439
+ """Unified model resolution - handles both ambiguous and missing models.
440
+
441
+ Args:
442
+ reference: The model reference from workflow
443
+ candidates: List of potential matches (empty for missing models)
444
+ context: Resolution context with search function and workflow info
445
+
446
+ Returns:
447
+ ResolvedModel with resolved_model set (or None for optional unresolved)
448
+ None to skip resolution
449
+ """
450
+ # Case 1: Multiple candidates (ambiguous)
451
+ if len(candidates) > 1:
452
+ return self._handle_ambiguous(reference, candidates, context)
453
+
454
+ # Case 2: Single candidate (confirm)
455
+ if len(candidates) == 1:
456
+ return self._handle_single_candidate(reference, candidates[0], context)
457
+
458
+ # Case 3: No candidates (missing - use search)
459
+ return self._handle_missing(reference, context)
460
+
461
+ def _handle_ambiguous(
462
+ self,
463
+ reference: WorkflowNodeWidgetRef,
464
+ candidates: list[ResolvedModel],
465
+ context: ModelResolutionContext
466
+ ) -> ResolvedModel | None:
467
+ """Handle ambiguous models (multiple matches)."""
468
+
469
+ print(f"\n🔍 Multiple matches for model in node #{reference.node_id}:")
470
+ print(f" Looking for: {reference.widget_value}")
471
+ print(" Found matches:")
472
+
473
+ display_count = min(10, len(candidates))
474
+ for i, resolved in enumerate(candidates[:display_count], 1):
475
+ model = resolved.resolved_model
476
+ if model:
477
+ size_mb = model.file_size / (1024 * 1024)
478
+ print(f" {i}. {model.relative_path} ({size_mb:.1f} MB)")
479
+
480
+ print("\n [1-9] - Select model")
481
+ print(" [o] - Mark as optional (select from above)")
482
+ print(" [s] - Skip")
483
+
484
+ choice = self._unified_choice_prompt(
485
+ "Choice [1]/o/s: ",
486
+ num_options=display_count,
487
+ has_browse=False
488
+ )
489
+
490
+ if choice == 's':
491
+ return None
492
+ elif choice == 'o':
493
+ # User wants to mark as optional - prompt for which model
494
+ model_choice = input(" Which model? [1]: ").strip() or "1"
495
+ idx = int(model_choice) - 1
496
+ selected = candidates[idx]
497
+ # Return as optional
498
+ return ResolvedModel(
499
+ workflow=context.workflow_name,
500
+ reference=reference,
501
+ resolved_model=selected.resolved_model,
502
+ is_optional=True,
503
+ match_type="user_confirmed",
504
+ match_confidence=1.0
505
+ )
506
+ else:
507
+ idx = int(choice) - 1
508
+ selected = candidates[idx]
509
+ if selected.resolved_model:
510
+ print(f" ✓ Selected: {selected.resolved_model.relative_path}")
511
+ return ResolvedModel(
512
+ workflow=context.workflow_name,
513
+ reference=reference,
514
+ resolved_model=selected.resolved_model,
515
+ is_optional=False,
516
+ match_type="user_confirmed",
517
+ match_confidence=1.0
518
+ )
519
+
520
+ def _handle_single_candidate(
521
+ self,
522
+ reference: WorkflowNodeWidgetRef,
523
+ candidate: ResolvedModel,
524
+ context: ModelResolutionContext
525
+ ) -> ResolvedModel | None:
526
+ """Handle single candidate (confirm with user)."""
527
+ model = candidate.resolved_model
528
+ if not model:
529
+ return None
530
+
531
+ print(f"\n✓ Found match for: {reference.widget_value}")
532
+ print(f" {model.relative_path} ({model.file_size / (1024 * 1024):.1f} MB)")
533
+
534
+ choice = input("Accept? [Y/n/o]: ").strip().lower()
535
+
536
+ if choice in ('', 'y', 'yes'):
537
+ return ResolvedModel(
538
+ workflow=context.workflow_name,
539
+ reference=reference,
540
+ resolved_model=model,
541
+ is_optional=False,
542
+ match_type="user_confirmed",
543
+ match_confidence=1.0
544
+ )
545
+ elif choice == 'o':
546
+ return ResolvedModel(
547
+ workflow=context.workflow_name,
548
+ reference=reference,
549
+ resolved_model=model,
550
+ is_optional=True,
551
+ match_type="user_confirmed",
552
+ match_confidence=1.0
553
+ )
554
+ else:
555
+ return None
556
+
557
+ def _handle_missing(
558
+ self,
559
+ reference: WorkflowNodeWidgetRef,
560
+ context: ModelResolutionContext
561
+ ) -> ResolvedModel | None:
562
+ """Handle missing models with search and refinement loop."""
563
+ print(f"\n⚠️ Model not found: {reference.widget_value}")
564
+ print(f" in node #{reference.node_id} ({reference.node_type})")
565
+
566
+ if not context.search_fn:
567
+ # No search available - can only mark optional, download, or skip
568
+ options = "\n [o] - Mark as optional"
569
+ if context.downloader:
570
+ options += "\n [d] - Download from URL"
571
+ options += "\n [s] - Skip"
572
+
573
+ prompt = "Choice [o]/s: " if not context.downloader else "Choice [o]/d/s: "
574
+ print(options)
575
+ choice = self._unified_choice_prompt(prompt, num_options=0, has_browse=False)
576
+
577
+ if choice == 'o':
578
+ return ResolvedModel(
579
+ workflow=context.workflow_name,
580
+ reference=reference,
581
+ resolved_model=None,
582
+ is_optional=True,
583
+ match_type="optional_unresolved",
584
+ match_confidence=1.0
585
+ )
586
+ elif choice == 'd' and context.downloader:
587
+ return self._handle_download(reference, context)
588
+ return None
589
+
590
+ # Search with refinement loop
591
+ search_term = reference.widget_value
592
+
593
+ while True:
594
+ print(f"\n🔍 Searching for: {search_term}")
595
+
596
+ results = context.search_fn(
597
+ search_term=search_term,
598
+ node_type=reference.node_type,
599
+ limit=5
600
+ )
601
+
602
+ if results:
603
+ # Show results with refinement option
604
+ result = self._show_search_results_with_refinement(
605
+ reference, results, context
606
+ )
607
+
608
+ if result == "REFINE":
609
+ # User wants to refine search
610
+ new_term = input("\nEnter new search term: ").strip()
611
+ if new_term:
612
+ search_term = new_term
613
+ continue
614
+ else:
615
+ print(" Keeping previous search term")
616
+ continue
617
+ else:
618
+ # User made a choice (model, optional, or skip)
619
+ assert isinstance(result, ResolvedModel) or result is None
620
+ return result
621
+ else:
622
+ # No results found
623
+ print(" No models found")
624
+ print("\n [r] - Refine search")
625
+ if context.downloader:
626
+ print(" [d] - Download from URL")
627
+ print(" [o] - Mark as optional")
628
+ print(" [s] - Skip")
629
+
630
+ prompt = "Choice [r]/o/s: " if not context.downloader else "Choice [r]/d/o/s: "
631
+ choice = self._unified_choice_prompt(prompt, num_options=0, has_browse=False)
632
+
633
+ if choice == 'r':
634
+ new_term = input("\nEnter new search term: ").strip()
635
+ if new_term:
636
+ search_term = new_term
637
+ continue
638
+ elif choice == 'd' and context.downloader:
639
+ result = self._handle_download(reference, context)
640
+ if result is not None:
641
+ return result
642
+ # User pressed back - continue to show menu again
643
+ continue
644
+ elif choice == 'o':
645
+ return ResolvedModel(
646
+ workflow=context.workflow_name,
647
+ reference=reference,
648
+ resolved_model=None,
649
+ is_optional=True,
650
+ match_type="optional_unresolved",
651
+ match_confidence=1.0
652
+ )
653
+ return None
654
+
655
+ def _show_search_results_with_refinement(
656
+ self,
657
+ reference: WorkflowNodeWidgetRef,
658
+ results: list[ScoredMatch],
659
+ context: ModelResolutionContext
660
+ ) -> ResolvedModel | None | str:
661
+ """Show search results with refinement option.
662
+
663
+ Shows up to 9 results max (per UX doc - no pagination).
664
+
665
+ Returns:
666
+ ResolvedModel - user selected a model
667
+ "REFINE" - user wants to refine search
668
+ None - user skipped
669
+ """
670
+ # Show up to 9 matches (UX doc spec - no pagination)
671
+ display_count = min(9, len(results))
672
+ print(f"\nFound {len(results)} matches:\n")
673
+
674
+ for i, match in enumerate(results[:display_count], 1):
675
+ model = match.model
676
+ size_gb = model.file_size / (1024 * 1024 * 1024)
677
+ confidence = match.confidence.capitalize()
678
+ print(f" {i}. {model.relative_path} ({size_gb:.2f} GB)")
679
+ print(f" {confidence} confidence match\n")
680
+
681
+ print(" [r] Refine search")
682
+ if context.downloader:
683
+ print(" [d] Download from URL")
684
+ print(" [o] Mark as optional")
685
+ print(" [s] Skip\n")
686
+
687
+ prompt = "Choice [1]/r/o/s: " if not context.downloader else "Choice [1]/r/d/o/s: "
688
+ choice = self._unified_choice_prompt(
689
+ prompt,
690
+ num_options=display_count,
691
+ has_browse=False
692
+ )
693
+
694
+ if choice == 's':
695
+ return None
696
+ elif choice == 'r':
697
+ return "REFINE"
698
+ elif choice == 'd' and context.downloader:
699
+ result = self._handle_download(reference, context)
700
+ if result is not None:
701
+ return result
702
+ # User pressed back - return to search results
703
+ return "REFINE"
704
+ elif choice == 'o':
705
+ return ResolvedModel(
706
+ workflow=context.workflow_name,
707
+ reference=reference,
708
+ resolved_model=None,
709
+ is_optional=True,
710
+ match_type="optional_unresolved",
711
+ match_confidence=1.0
712
+ )
713
+ else:
714
+ idx = int(choice) - 1
715
+ selected = results[idx].model
716
+ print(f"\n✓ Selected: {selected.relative_path}")
717
+ return ResolvedModel(
718
+ workflow=context.workflow_name,
719
+ reference=reference,
720
+ resolved_model=selected,
721
+ is_optional=False,
722
+ match_type="user_confirmed",
723
+ match_confidence=1.0
724
+ )
725
+
726
+
727
+ def _handle_download(
728
+ self,
729
+ reference: WorkflowNodeWidgetRef,
730
+ context: ModelResolutionContext
731
+ ) -> ResolvedModel | None:
732
+ """Handle download intent collection with path confirmation.
733
+
734
+ Returns download intent instead of immediately downloading the model.
735
+ Actual downloads happen in batch at the end of resolution.
736
+
737
+ Args:
738
+ reference: Model reference from workflow
739
+ context: Resolution context
740
+
741
+ Returns:
742
+ ResolvedModel with download_intent if user provides URL, None to skip
743
+ """
744
+ from pathlib import Path
745
+
746
+ if not context.downloader:
747
+ print(" Download not available")
748
+ return None
749
+
750
+ # Step 1: Get URL
751
+ url = input("\nEnter download URL: ").strip()
752
+ if not url:
753
+ print(" Cancelled")
754
+ return None
755
+
756
+ # Step 2: Suggest path
757
+ suggested_path = context.downloader.suggest_path(
758
+ url=url,
759
+ node_type=reference.node_type,
760
+ filename_hint=reference.widget_value
761
+ )
762
+
763
+ # Step 3: Path confirmation loop
764
+ while True:
765
+ print("\nModel will be downloaded to:")
766
+ print(f" {suggested_path}")
767
+ print("\n[Y] Continue [m] Change path [b] Back to menu")
768
+
769
+ choice = input("Choice [Y]/m/b: ").strip().lower()
770
+
771
+ if choice == 'b':
772
+ return None # Back to menu
773
+ elif choice == 'm':
774
+ new_path = input("Enter path: ").strip()
775
+ if new_path:
776
+ suggested_path = Path(new_path)
777
+ continue
778
+ elif choice in ('', 'y'):
779
+ break
780
+
781
+ # Step 4: Return download intent (actual download happens in batch at end)
782
+ print(f" ✓ Download queued: {suggested_path}")
783
+ return ResolvedModel(
784
+ workflow=context.workflow_name,
785
+ reference=reference,
786
+ resolved_model=None, # Not downloaded yet
787
+ model_source=url,
788
+ is_optional=False,
789
+ match_type="download_intent",
790
+ target_path=suggested_path
791
+ )
792
+
793
+ def _browse_all_models(self, results: list[ScoredMatch]) -> ModelWithLocation | str | None:
794
+ """Browse all fuzzy search results with pagination.
795
+
796
+ Returns:
797
+ ModelWithLocation if user selects a model
798
+ "BACK" if user cancels
799
+ None if user quits
800
+ """
801
+ page = 0
802
+ page_size = 10
803
+ total_pages = (len(results) + page_size - 1) // page_size
804
+
805
+ while True:
806
+ start = page * page_size
807
+ end = min(start + page_size, len(results))
808
+
809
+ print(f"\nAll matches (Page {page + 1}/{total_pages}):\n")
810
+
811
+ for i, match in enumerate(results[start:end], start + 1):
812
+ model = match.model
813
+ size_gb = model.file_size / (1024 * 1024 * 1024)
814
+ print(f" {i}. {model.relative_path} ({size_gb:.2f} GB)")
815
+
816
+ print("\n[N]ext, [P]rev, number, [B]ack, or [Q]uit:")
817
+
818
+ choice = input("Choice: ").strip().lower()
819
+
820
+ if choice == 'n':
821
+ if page < total_pages - 1:
822
+ page += 1
823
+ else:
824
+ print(" Already on last page")
825
+ elif choice == 'p':
826
+ if page > 0:
827
+ page -= 1
828
+ else:
829
+ print(" Already on first page")
830
+ elif choice == 'b':
831
+ return "BACK"
832
+ elif choice == 'q':
833
+ return None
834
+ elif choice.isdigit():
835
+ idx = int(choice) - 1
836
+ if 0 <= idx < len(results):
837
+ return results[idx].model
838
+ else:
839
+ print(" Invalid number")
840
+ else:
841
+ print(" Invalid choice")
842
+
843
+