stratifyai 0.1.1__py3-none-any.whl → 0.1.3__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.
cli/stratifyai_cli.py CHANGED
@@ -32,6 +32,21 @@ app = typer.Typer(
32
32
  )
33
33
  console = Console()
34
34
 
35
+ # Mode-specific colors and icons
36
+ CHAT_COLOR = "magenta"
37
+ CHAT_ACCENT = "bold magenta"
38
+ CHAT_ICON = "💬"
39
+ INTERACTIVE_COLOR = "cyan"
40
+ INTERACTIVE_ACCENT = "bold cyan"
41
+ INTERACTIVE_ICON = "⚡"
42
+
43
+
44
+ def mode_prompt(text: str, mode: str = "chat") -> str:
45
+ """Add mode icon prefix to prompt text."""
46
+ icon = CHAT_ICON if mode == "chat" else INTERACTIVE_ICON
47
+ color = CHAT_ACCENT if mode == "chat" else INTERACTIVE_ACCENT
48
+ return f"[{color}]{icon}[/{color}] {text}"
49
+
35
50
 
36
51
  @app.command()
37
52
  def chat(
@@ -122,6 +137,10 @@ def _chat_impl(
122
137
  _conversation_history: Optional[List[Message]] = None,
123
138
  ):
124
139
  """Internal implementation of chat with conversation history support."""
140
+ # Show mode banner
141
+ console.print(f"\n[{CHAT_ACCENT}]─── 💬 CHAT MODE ───[/{CHAT_ACCENT}]")
142
+ console.print(f"[dim]Single message mode - use 'interactive' for conversations[/dim]\n")
143
+
125
144
  try:
126
145
  # Auto-select model based on file type if enabled
127
146
  if auto_select and file and not (provider and model):
@@ -152,7 +171,7 @@ def _chat_impl(
152
171
  # Retry loop for provider selection
153
172
  max_attempts = 3
154
173
  for attempt in range(max_attempts):
155
- provider_choice = Prompt.ask("\nChoose provider", default="1")
174
+ provider_choice = Prompt.ask(mode_prompt("Choose provider", "chat"), default="1")
156
175
 
157
176
  try:
158
177
  provider_idx = int(provider_choice) - 1
@@ -174,25 +193,70 @@ def _chat_impl(
174
193
  console.print("[yellow]Too many invalid attempts. Using default: openai[/yellow]")
175
194
  provider = "openai"
176
195
 
196
+ # Model selection with vision validation loop
197
+ need_vision_model = file and file.suffix.lower() in {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'} if file else False
198
+
177
199
  if not model:
178
200
  prompted_for_model = True
179
- # Show available models for selected provider
201
+ # Validate and display models for selected provider
202
+ from stratifyai.utils.provider_validator import get_validated_interactive_models
203
+
180
204
  if provider in MODEL_CATALOG:
181
- console.print(f"\n[bold cyan]Available models for {provider}:[/bold cyan]")
182
- available_models = list(MODEL_CATALOG[provider].keys())
205
+ # Show spinner while validating
206
+ with console.status(f"[cyan]Validating {provider} models...", spinner="dots"):
207
+ validation_data = get_validated_interactive_models(provider)
208
+
209
+ validation_result = validation_data["validation_result"]
210
+ validated_models = validation_data["models"]
211
+
212
+ # Show validation result
213
+ if validation_result["error"]:
214
+ console.print("[yellow]⚠ Default models displayed. Could not validate models.[/yellow]")
215
+ # Fall back to MODEL_CATALOG if validation fails
216
+ available_models = list(MODEL_CATALOG[provider].keys())
217
+ model_metadata = MODEL_CATALOG[provider]
218
+ else:
219
+ console.print(f"[green]✓ Validated {len(validated_models)} models[/green] [dim]({validation_result['validation_time_ms']}ms)[/dim]")
220
+ available_models = list(validated_models.keys())
221
+ model_metadata = validated_models
222
+
223
+ # Filter for vision models if image file provided
224
+ if need_vision_model:
225
+ vision_models = [m for m in available_models if model_metadata.get(m, {}).get("supports_vision", False)]
226
+ if vision_models:
227
+ console.print(f"\n[bold cyan]Vision-capable models for {provider}:[/bold cyan]")
228
+ console.print("[dim](Filtered for image file support)[/dim]")
229
+ available_models = vision_models
230
+ else:
231
+ console.print(f"\n[yellow]⚠ No vision-capable models available for {provider}[/yellow]")
232
+ console.print("[yellow]Please select a different provider or remove the image file[/yellow]")
233
+ raise typer.Exit(1)
234
+ else:
235
+ console.print(f"\n[bold cyan]Available {provider} models:[/bold cyan]")
236
+
237
+ # Display with friendly names, descriptions, and categories (same as interactive mode)
238
+ current_category = None
183
239
  for i, m in enumerate(available_models, 1):
184
- model_info = MODEL_CATALOG[provider][m]
185
- is_reasoning = model_info.get("reasoning_model", False)
186
- label = f" {i}. {m}"
187
- if is_reasoning:
188
- label += " [yellow](reasoning)[/yellow]"
240
+ meta = model_metadata.get(m, {})
241
+ display_name = meta.get("display_name", m)
242
+ description = meta.get("description", "")
243
+ category = meta.get("category", "")
244
+
245
+ # Show category header if changed
246
+ if category and category != current_category:
247
+ console.print(f" [dim]── {category} ──[/dim]")
248
+ current_category = category
249
+
250
+ label = f" {i}. {display_name}"
251
+ if description:
252
+ label += f" [dim]- {description}[/dim]"
189
253
  console.print(label)
190
-
254
+
191
255
  # Retry loop for model selection
192
256
  max_attempts = 3
193
257
  model = None
194
258
  for attempt in range(max_attempts):
195
- model_choice = Prompt.ask("\nSelect model")
259
+ model_choice = Prompt.ask(mode_prompt("Select model", "chat"))
196
260
 
197
261
  try:
198
262
  model_idx = int(model_choice) - 1
@@ -230,7 +294,7 @@ def _chat_impl(
230
294
  temperature = None
231
295
  for attempt in range(max_attempts):
232
296
  temp_input = Prompt.ask(
233
- "\n[bold cyan]Temperature[/bold cyan] (0.0-2.0, default 0.7)",
297
+ mode_prompt("Temperature (0.0-2.0)", "chat"),
234
298
  default="0.7"
235
299
  )
236
300
 
@@ -260,20 +324,114 @@ def _chat_impl(
260
324
  console.print(f"[dim]Attach a file to include its content in your message[/dim]")
261
325
  console.print(f"[dim]Max file size: 5 MB | Leave blank to skip[/dim]")
262
326
 
263
- file_path_input = Prompt.ask("\nFile path (or press Enter to skip)", default="")
264
-
265
- if file_path_input.strip():
327
+ # File prompt with retry loop
328
+ max_file_attempts = 3
329
+ file = None
330
+ for file_attempt in range(max_file_attempts):
331
+ file_path_input = Prompt.ask(mode_prompt("File path (or Enter to skip)", "chat"), default="")
332
+
333
+ if not file_path_input.strip():
334
+ # User pressed Enter to skip
335
+ break
336
+
266
337
  file = Path(file_path_input.strip()).expanduser()
338
+
339
+ # Validate file exists and is readable
340
+ if not file.exists():
341
+ console.print(f"[red]✗ File not found: {file}[/red]")
342
+ if file_attempt < max_file_attempts - 1:
343
+ choice = Prompt.ask(
344
+ "[cyan]Enter 1 to retry file path or 2 to continue without file[/cyan]",
345
+ choices=["1", "2"],
346
+ default="2"
347
+ )
348
+ if choice == "2":
349
+ file = None
350
+ break
351
+ # Otherwise loop continues for retry
352
+ else:
353
+ console.print("[dim]Continuing without file attachment[/dim]")
354
+ file = None
355
+ elif not file.is_file():
356
+ console.print(f"[red]✗ Path is not a file: {file}[/red]")
357
+ if file_attempt < max_file_attempts - 1:
358
+ choice = Prompt.ask(
359
+ "[cyan]Enter 1 to retry file path or 2 to continue without file[/cyan]",
360
+ choices=["1", "2"],
361
+ default="2"
362
+ )
363
+ if choice == "2":
364
+ file = None
365
+ break
366
+ else:
367
+ console.print("[dim]Continuing without file attachment[/dim]")
368
+ file = None
369
+ else:
370
+ # File is valid, break out of retry loop
371
+ break
267
372
 
268
373
  # Load content from file if provided
269
374
  file_content = None
270
375
  if file:
271
376
  try:
272
- with open(file, 'r', encoding='utf-8') as f:
273
- file_content = f.read()
377
+ # Check if file is an image
378
+ image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
379
+ is_image = file.suffix.lower() in image_extensions
274
380
 
275
- # Get file size for display (only if file exists as Path object)
276
- try:
381
+ if is_image:
382
+ # For image files, check if model supports vision
383
+ model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
384
+ supports_vision = model_info.get("supports_vision", False)
385
+
386
+ if not supports_vision:
387
+ console.print(f"\n[red]✗ Vision not supported: {model} cannot process image files[/red]")
388
+ console.print("[yellow]⚠️ This model cannot process images. Please select a vision-capable model.[/yellow]")
389
+ console.print("\n[cyan]Returning to model selection...[/cyan]\n")
390
+
391
+ # Return to model selection - call chat command recursively with vision-required flag
392
+ # Pass message=None to force prompting for message after model selection
393
+ import sys
394
+ sys.argv = ['stratifyai', 'chat', '--provider', provider, '--file', str(file)]
395
+ if system:
396
+ sys.argv.extend(['--system', system])
397
+ if max_tokens:
398
+ sys.argv.extend(['--max-tokens', str(max_tokens)])
399
+ chat(provider=provider, model=None, message=None, system=system,
400
+ temperature=temperature, max_tokens=max_tokens, file=file,
401
+ stream=stream, cache_control=cache_control, chunked=chunked,
402
+ chunk_size=chunk_size, auto_select=auto_select)
403
+ return
404
+
405
+ # Read image as base64
406
+ import base64
407
+ with open(file, 'rb') as f:
408
+ image_data = base64.b64encode(f.read()).decode('utf-8')
409
+
410
+ # Get file size
411
+ file_size = file.stat().st_size
412
+ file_size_kb = file_size / 1024
413
+ file_size_mb = file_size / (1024 * 1024)
414
+
415
+ size_str = f"{file_size_kb:.1f} KB" if file_size_kb < 1024 else f"{file_size_mb:.2f} MB"
416
+ console.print(f"[green]✓ Loaded {file.name}[/green] [dim]({size_str}, image)[/dim]")
417
+
418
+ # Return base64 image data with metadata
419
+ mime_type = {
420
+ '.jpg': 'image/jpeg',
421
+ '.jpeg': 'image/jpeg',
422
+ '.png': 'image/png',
423
+ '.gif': 'image/gif',
424
+ '.webp': 'image/webp',
425
+ '.bmp': 'image/bmp'
426
+ }.get(file.suffix.lower(), 'image/jpeg')
427
+
428
+ file_content = f"[IMAGE:{mime_type}]\n{image_data}"
429
+ else:
430
+ # Read text file
431
+ with open(file, 'r', encoding='utf-8') as f:
432
+ file_content = f.read()
433
+
434
+ # Get file size for display
277
435
  if isinstance(file, Path) and file.exists():
278
436
  file_size = file.stat().st_size
279
437
  file_size_mb = file_size / (1024 * 1024)
@@ -298,19 +456,19 @@ def _chat_impl(
298
456
 
299
457
  if analysis.warning:
300
458
  console.print(f"[yellow]⚠ {analysis.warning}[/yellow]")
301
- else:
302
- # Fallback for tests or non-Path objects
303
- console.print(f"[dim]Loaded content from {file} ({len(file_content)} chars)[/dim]")
304
- except:
305
- # Fallback if stat fails (e.g., in test environments)
306
- console.print(f"[dim]Loaded content from {file} ({len(file_content)} chars)[/dim]")
307
459
  except Exception as e:
308
460
  console.print(f"[red]Error reading file {file}: {e}[/red]")
309
461
  raise typer.Exit(1)
310
462
 
311
- if not message and not file_content:
312
- console.print("\n[bold cyan]Enter your message:[/bold cyan]")
313
- message = Prompt.ask("Message")
463
+ # Prompt for message if not provided
464
+ # For image files, always prompt (user needs to provide instructions for the image)
465
+ # For text files, only prompt if no file content
466
+ is_image_file = file and file.suffix.lower() in {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'} if file else False
467
+
468
+ if not message:
469
+ if is_image_file or not file_content:
470
+ console.print(f"\n[{CHAT_ACCENT}]Enter your message:[/{CHAT_ACCENT}]")
471
+ message = Prompt.ask(mode_prompt("Message", "chat"))
314
472
 
315
473
  # Build messages - use conversation history if this is a follow-up
316
474
  if _conversation_history is None:
@@ -393,8 +551,8 @@ def _chat_impl(
393
551
  response = client.chat_completion_sync(request)
394
552
  response_content = response.content
395
553
 
396
- # Display metadata before response
397
- console.print(f"\n[bold]Provider:[/bold] [cyan]{provider}[/cyan] | [bold]Model:[/bold] [cyan]{model}[/cyan]")
554
+ # Display metadata before response (chat mode - magenta)
555
+ console.print(f"\n[bold]Provider:[/bold] [{CHAT_COLOR}]{provider}[/{CHAT_COLOR}] | [bold]Model:[/bold] [{CHAT_COLOR}]{model}[/{CHAT_COLOR}]")
398
556
 
399
557
  # Build usage line with token breakdown and cache info
400
558
  usage_parts = [
@@ -419,15 +577,15 @@ def _chat_impl(
419
577
 
420
578
  console.print(f"[dim]{' | '.join(usage_parts)}[/dim]")
421
579
 
422
- # Print response with Rich formatting
423
- console.print(f"\n{response_content}", style="cyan")
580
+ # Print response with chat mode color (magenta)
581
+ console.print(f"\n{response_content}", style=CHAT_COLOR)
424
582
 
425
583
  # Add assistant response to history for multi-turn conversation
426
584
  messages.append(Message(role="assistant", content=response_content))
427
585
 
428
586
  # Ask what to do next
429
587
  console.print("\n[dim]Options: [1] Continue conversation [2] Save & continue [3] Save & exit [4] Exit[/dim]")
430
- next_action = Prompt.ask("What would you like to do?", choices=["1", "2", "3", "4"], default="1")
588
+ next_action = Prompt.ask(mode_prompt("What would you like to do?", "chat"), choices=["1", "2", "3", "4"], default="1")
431
589
 
432
590
  # Handle save requests
433
591
  if next_action in ["2", "3"]:
@@ -725,14 +883,200 @@ def interactive(
725
883
 
726
884
  try:
727
885
  # Helper function to load file with size validation and intelligent extraction
728
- def load_file_content(file_path: Path, warn_large: bool = True) -> Optional[str]:
729
- """Load file content with size restrictions, warnings, and intelligent extraction."""
886
+ def load_file_content(file_path: Path, warn_large: bool = True, check_vision: bool = False) -> Optional[str]:
887
+ """Load file content with size restrictions, warnings, and intelligent extraction.
888
+
889
+ Args:
890
+ file_path: Path to the file to load
891
+ warn_large: Whether to warn about large files
892
+ check_vision: Whether to check for vision support for image files
893
+ """
894
+ # Declare nonlocal variables at the top before any use
895
+ nonlocal model, client, temperature
896
+
730
897
  try:
731
898
  # Check if file exists
732
899
  if not file_path.exists():
733
900
  console.print(f"[red]✗ File not found: {file_path}[/red]")
734
901
  return None
735
902
 
903
+ # Check if file is an image
904
+ image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'}
905
+ is_image = file_path.suffix.lower() in image_extensions
906
+
907
+ if is_image:
908
+ # For image files, check if model supports vision
909
+ if check_vision:
910
+ model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
911
+ supports_vision = model_info.get("supports_vision", False)
912
+
913
+ if not supports_vision:
914
+ console.print(f"\n[red]✗ Vision not supported: {model} cannot process image files[/red]")
915
+ console.print("[yellow]⚠️ This model cannot process images. Switching to vision-capable model...[/yellow]")
916
+
917
+ # Offer to switch to a vision model
918
+ choice = Prompt.ask(
919
+ "[cyan]Enter 1 to select a vision-capable model or 2 to continue without image[/cyan]",
920
+ choices=["1", "2"],
921
+ default="1"
922
+ )
923
+
924
+ if choice == "1":
925
+ # Show vision-capable models for current provider (like chat mode)
926
+ from stratifyai.utils.provider_validator import get_validated_interactive_models
927
+
928
+ # Validate and get models
929
+ with console.status(f"[cyan]Validating {provider} models...", spinner="dots"):
930
+ validation_data = get_validated_interactive_models(provider)
931
+
932
+ validation_result = validation_data["validation_result"]
933
+ validated_models = validation_data["models"]
934
+
935
+ # Get model metadata
936
+ if validation_result["error"]:
937
+ available_models = list(MODEL_CATALOG[provider].keys())
938
+ model_metadata = MODEL_CATALOG[provider]
939
+ else:
940
+ available_models = list(validated_models.keys())
941
+ model_metadata = validated_models
942
+
943
+ # Filter for vision models
944
+ vision_models = [m for m in available_models if model_metadata.get(m, {}).get("supports_vision", False)]
945
+
946
+ if not vision_models:
947
+ console.print(f"\n[yellow]⚠ No vision-capable models available for {provider}[/yellow]")
948
+ console.print("[yellow]Please use /provider to select a different provider[/yellow]\n")
949
+ return None
950
+
951
+ # Show vision models
952
+ console.print(f"\n[bold cyan]Vision-capable models for {provider}:[/bold cyan]")
953
+ console.print("[dim](Filtered for image file support)[/dim]")
954
+
955
+ # Display with categories and descriptions
956
+ current_category = None
957
+ for i, m in enumerate(vision_models, 1):
958
+ meta = model_metadata.get(m, {})
959
+ display_name = meta.get("display_name", m)
960
+ description = meta.get("description", "")
961
+ category = meta.get("category", "")
962
+
963
+ # Show category header if changed
964
+ if category and category != current_category:
965
+ console.print(f" [dim]── {category} ──[/dim]")
966
+ current_category = category
967
+
968
+ current_marker = " [green](current)[/green]" if m == model else ""
969
+ label = f" {i}. {display_name}{current_marker}"
970
+ if description:
971
+ label += f" [dim]- {description}[/dim]"
972
+ console.print(label)
973
+
974
+ # Get model selection
975
+ max_attempts = 3
976
+ new_model = None
977
+ for attempt in range(max_attempts):
978
+ model_choice = Prompt.ask("\nSelect vision model")
979
+ try:
980
+ model_idx = int(model_choice) - 1
981
+ if 0 <= model_idx < len(vision_models):
982
+ new_model = vision_models[model_idx]
983
+ break
984
+ else:
985
+ console.print(f"[red]✗ Invalid number.[/red] Please enter a number between 1 and {len(vision_models)}")
986
+ if attempt < max_attempts - 1:
987
+ console.print("[dim]Try again...[/dim]")
988
+ except ValueError:
989
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number")
990
+ if attempt < max_attempts - 1:
991
+ console.print("[dim]Try again...[/dim]")
992
+
993
+ if new_model:
994
+ # Update the outer scope model variable
995
+ model = new_model
996
+
997
+ # Check if new model has fixed temperature and prompt if needed
998
+ new_model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
999
+ fixed_temp = new_model_info.get("fixed_temperature")
1000
+
1001
+ if fixed_temp is not None:
1002
+ temperature = fixed_temp
1003
+ console.print(f"\n[dim]Using fixed temperature: {fixed_temp} for this model[/dim]")
1004
+ else:
1005
+ # Retry loop for temperature input
1006
+ max_temp_attempts = 3
1007
+ temperature = None
1008
+ for temp_attempt in range(max_temp_attempts):
1009
+ temp_input = Prompt.ask(
1010
+ "\n[bold cyan]Temperature[/bold cyan] (0.0-2.0, default 0.7)",
1011
+ default="0.7"
1012
+ )
1013
+
1014
+ try:
1015
+ temp_value = float(temp_input)
1016
+ if 0.0 <= temp_value <= 2.0:
1017
+ temperature = temp_value
1018
+ break
1019
+ else:
1020
+ console.print("[red]✗ Out of range.[/red] Temperature must be between 0.0 and 2.0")
1021
+ if temp_attempt < max_temp_attempts - 1:
1022
+ console.print("[dim]Try again...[/dim]")
1023
+ except ValueError:
1024
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number (e.g., '0.7' not '{temp_input}')")
1025
+ if temp_attempt < max_temp_attempts - 1:
1026
+ console.print("[dim]Try again...[/dim]")
1027
+
1028
+ # If still no valid temperature after retries, use default
1029
+ if temperature is None:
1030
+ console.print("[yellow]Too many invalid attempts. Using default: 0.7[/yellow]")
1031
+ temperature = 0.7
1032
+
1033
+ # Reinitialize client with new model
1034
+ client = LLMClient(provider=provider)
1035
+
1036
+ # Update context window info
1037
+ context_window = new_model_info.get("context", "N/A")
1038
+
1039
+ console.print(f"\n[green]✓ Switched to:[/green] [cyan]{model}[/cyan] | [dim]Context: {context_window:,} tokens[/dim]")
1040
+
1041
+ # Now try loading the image again (recursively call with updated model)
1042
+ return load_file_content(file_path, warn_large, check_vision=True)
1043
+ else:
1044
+ console.print("[yellow]Model not changed[/yellow]")
1045
+
1046
+ return None
1047
+
1048
+ # Read image as base64
1049
+ import base64
1050
+ with open(file_path, 'rb') as f:
1051
+ image_data = base64.b64encode(f.read()).decode('utf-8')
1052
+
1053
+ # Get file size
1054
+ file_size = file_path.stat().st_size
1055
+ file_size_mb = file_size / (1024 * 1024)
1056
+ file_size_kb = file_size / 1024
1057
+
1058
+ # Check size limit
1059
+ if file_size > MAX_FILE_SIZE_BYTES:
1060
+ console.print(f"[red]✗ Image too large: {file_size_mb:.2f} MB (max {MAX_FILE_SIZE_MB} MB)[/red]")
1061
+ return None
1062
+
1063
+ size_str = f"{file_size_kb:.1f} KB" if file_size_kb < 1024 else f"{file_size_mb:.2f} MB"
1064
+ console.print(f"[green]✓ Loaded {file_path.name}[/green] [dim]({size_str}, image)[/dim]")
1065
+
1066
+ # Return base64 image data with metadata
1067
+ mime_type = {
1068
+ '.jpg': 'image/jpeg',
1069
+ '.jpeg': 'image/jpeg',
1070
+ '.png': 'image/png',
1071
+ '.gif': 'image/gif',
1072
+ '.webp': 'image/webp',
1073
+ '.bmp': 'image/bmp'
1074
+ }.get(file_path.suffix.lower(), 'image/jpeg')
1075
+
1076
+ return f"[IMAGE:{mime_type}]\n{image_data}"
1077
+
1078
+ # For non-image files, continue with existing logic
1079
+
736
1080
  # Check file size
737
1081
  file_size = file_path.stat().st_size
738
1082
  file_size_mb = file_size / (1024 * 1024)
@@ -838,7 +1182,7 @@ def interactive(
838
1182
  # Retry loop for provider selection
839
1183
  max_attempts = 3
840
1184
  for attempt in range(max_attempts):
841
- provider_choice = Prompt.ask("\nChoose provider", default="1")
1185
+ provider_choice = Prompt.ask(mode_prompt("Choose provider", "interactive"), default="1")
842
1186
 
843
1187
  try:
844
1188
  provider_idx = int(provider_choice) - 1
@@ -873,8 +1217,7 @@ def interactive(
873
1217
 
874
1218
  # Show validation result
875
1219
  if validation_result["error"]:
876
- console.print(f"[yellow]⚠ {validation_result['error']}[/yellow]")
877
- console.print("[dim]Showing curated models (availability not confirmed)[/dim]")
1220
+ console.print("[yellow]⚠ Default models displayed. Could not validate models.[/yellow]")
878
1221
  else:
879
1222
  console.print(f"[green]✓ Validated {len(validated_models)} models[/green] [dim]({validation_result['validation_time_ms']}ms)[/dim]")
880
1223
 
@@ -945,7 +1288,7 @@ def interactive(
945
1288
  max_attempts = 3
946
1289
  model = None
947
1290
  for attempt in range(max_attempts):
948
- model_choice = Prompt.ask("\nSelect model")
1291
+ model_choice = Prompt.ask(mode_prompt("Select model", "interactive"))
949
1292
 
950
1293
  try:
951
1294
  model_idx = int(model_choice) - 1
@@ -966,12 +1309,47 @@ def interactive(
966
1309
  console.print(f"[red]Too many invalid attempts. Exiting.[/red]")
967
1310
  raise typer.Exit(1)
968
1311
 
1312
+ # Check if model has fixed temperature and prompt if needed
1313
+ model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
1314
+ fixed_temp = model_info.get("fixed_temperature")
1315
+
1316
+ if fixed_temp is not None:
1317
+ temperature = fixed_temp
1318
+ console.print(f"\n[dim]Using fixed temperature: {fixed_temp} for this model[/dim]")
1319
+ else:
1320
+ # Retry loop for temperature input
1321
+ max_attempts = 3
1322
+ temperature = None
1323
+ for attempt in range(max_attempts):
1324
+ temp_input = Prompt.ask(
1325
+ mode_prompt("Temperature (0.0-2.0)", "interactive"),
1326
+ default="0.7"
1327
+ )
1328
+
1329
+ try:
1330
+ temp_value = float(temp_input)
1331
+ if 0.0 <= temp_value <= 2.0:
1332
+ temperature = temp_value
1333
+ break
1334
+ else:
1335
+ console.print("[red]✗ Out of range.[/red] Temperature must be between 0.0 and 2.0")
1336
+ if attempt < max_attempts - 1:
1337
+ console.print("[dim]Try again...[/dim]")
1338
+ except ValueError:
1339
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number (e.g., '0.7' not '{temp_input}')")
1340
+ if attempt < max_attempts - 1:
1341
+ console.print("[dim]Try again...[/dim]")
1342
+
1343
+ # If still no valid temperature after retries, use default
1344
+ if temperature is None:
1345
+ console.print("[yellow]Too many invalid attempts. Using default: 0.7[/yellow]")
1346
+ temperature = 0.7
1347
+
969
1348
  # Initialize client
970
1349
  client = LLMClient(provider=provider)
971
1350
  messages: List[Message] = []
972
1351
 
973
- # Get model info for context window
974
- model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
1352
+ # Get model info for context window (already retrieved above for temperature check)
975
1353
  context_window = model_info.get("context", "N/A")
976
1354
 
977
1355
  # Set conversation history limit (reserve 80% for history, 20% for response)
@@ -993,15 +1371,55 @@ def interactive(
993
1371
  console.print(f"[dim]Load a file to provide context for the conversation[/dim]")
994
1372
  console.print(f"[dim]Max file size: {MAX_FILE_SIZE_MB} MB | Leave blank to skip[/dim]")
995
1373
 
996
- file_path_input = Prompt.ask("\nFile path (or press Enter to skip)", default="")
997
-
998
- if file_path_input.strip():
1374
+ # File prompt with retry loop
1375
+ max_file_attempts = 3
1376
+ for file_attempt in range(max_file_attempts):
1377
+ file_path_input = Prompt.ask(mode_prompt("File path (or Enter to skip)", "interactive"), default="")
1378
+
1379
+ if not file_path_input.strip():
1380
+ # User pressed Enter to skip
1381
+ break
1382
+
999
1383
  file = Path(file_path_input.strip()).expanduser()
1384
+
1385
+ # Validate file exists and is readable
1386
+ if not file.exists():
1387
+ console.print(f"[red]✗ File not found: {file}[/red]")
1388
+ if file_attempt < max_file_attempts - 1:
1389
+ choice = Prompt.ask(
1390
+ "[cyan]Enter 1 to retry file path or 2 to continue without file[/cyan]",
1391
+ choices=["1", "2"],
1392
+ default="2"
1393
+ )
1394
+ if choice == "2":
1395
+ file = None
1396
+ break
1397
+ # Otherwise loop continues for retry
1398
+ else:
1399
+ console.print("[dim]Continuing without file[/dim]")
1400
+ file = None
1401
+ elif not file.is_file():
1402
+ console.print(f"[red]✗ Path is not a file: {file}[/red]")
1403
+ if file_attempt < max_file_attempts - 1:
1404
+ choice = Prompt.ask(
1405
+ "[cyan]Enter 1 to retry file path or 2 to continue without file[/cyan]",
1406
+ choices=["1", "2"],
1407
+ default="2"
1408
+ )
1409
+ if choice == "2":
1410
+ file = None
1411
+ break
1412
+ else:
1413
+ console.print("[dim]Continuing without file[/dim]")
1414
+ file = None
1415
+ else:
1416
+ # File is valid, break out of retry loop
1417
+ break
1000
1418
 
1001
1419
  # Load initial file if provided
1002
1420
  if file:
1003
1421
  console.print(f"\n[bold cyan]Loading initial context...[/bold cyan]")
1004
- file_content = load_file_content(file, warn_large=True)
1422
+ file_content = load_file_content(file, warn_large=True, check_vision=True)
1005
1423
  if file_content:
1006
1424
  messages.append(Message(
1007
1425
  role="user",
@@ -1009,14 +1427,15 @@ def interactive(
1009
1427
  ))
1010
1428
  console.print(f"[dim]File loaded as initial context[/dim]")
1011
1429
 
1012
- # Welcome message
1013
- console.print(f"\n[bold green]StratifyAI Interactive Mode[/bold green]")
1430
+ # Welcome message with mode banner
1431
+ console.print(f"\n[{INTERACTIVE_ACCENT}]═══ INTERACTIVE MODE ═══[/{INTERACTIVE_ACCENT}]")
1432
+ console.print(f"[dim]Multi-turn conversation with context preservation[/dim]\n")
1014
1433
 
1015
1434
  # Display context info with API limit warning if applicable
1016
1435
  if api_max_input and api_max_input < context_window:
1017
- console.print(f"Provider: [cyan]{provider}[/cyan] | Model: [cyan]{model}[/cyan] | Context: [cyan]{context_window:,} tokens[/cyan] [yellow](API limit: {api_max_input:,})[/yellow]")
1436
+ console.print(f"Provider: [{INTERACTIVE_COLOR}]{provider}[/{INTERACTIVE_COLOR}] | Model: [{INTERACTIVE_COLOR}]{model}[/{INTERACTIVE_COLOR}] | Context: [{INTERACTIVE_COLOR}]{context_window:,} tokens[/{INTERACTIVE_COLOR}] [yellow](API limit: {api_max_input:,})[/yellow]")
1018
1437
  else:
1019
- console.print(f"Provider: [cyan]{provider}[/cyan] | Model: [cyan]{model}[/cyan] | Context: [cyan]{context_window:,} tokens[/cyan]")
1438
+ console.print(f"Provider: [{INTERACTIVE_COLOR}]{provider}[/{INTERACTIVE_COLOR}] | Model: [{INTERACTIVE_COLOR}]{model}[/{INTERACTIVE_COLOR}] | Context: [{INTERACTIVE_COLOR}]{context_window:,} tokens[/{INTERACTIVE_COLOR}]")
1020
1439
 
1021
1440
  console.print("[dim]Commands: /file <path> | /attach <path> | /clear | /save [path] | /provider | /help | exit[/dim]")
1022
1441
  console.print(f"[dim]File size limit: {MAX_FILE_SIZE_MB} MB | Ctrl+C to exit[/dim]\n")
@@ -1027,16 +1446,16 @@ def interactive(
1027
1446
  last_response = None # Track last assistant response for /save command
1028
1447
 
1029
1448
  while True:
1030
- # Show staged file indicator
1031
- prompt_text = "[bold blue]You[/bold blue]"
1449
+ # Show staged file indicator with interactive mode styling
1450
+ prompt_text = f"[{INTERACTIVE_ACCENT}]⚡ You[/{INTERACTIVE_ACCENT}]"
1032
1451
  if staged_file_content:
1033
- prompt_text = f"[bold blue]You[/bold blue] [dim]📎 {staged_file_name}[/dim]"
1452
+ prompt_text = f"[{INTERACTIVE_ACCENT}]⚡ You[/{INTERACTIVE_ACCENT}] [dim]📎 {staged_file_name}[/dim]"
1034
1453
 
1035
1454
  # Get user input
1036
1455
  try:
1037
1456
  user_input = Prompt.ask(prompt_text)
1038
1457
  except (KeyboardInterrupt, EOFError):
1039
- console.print("\n[dim]Exiting...[/dim]")
1458
+ console.print("\n[dim]Exiting interactive mode...[/dim]")
1040
1459
  break
1041
1460
 
1042
1461
  # Check for exit commands
@@ -1046,24 +1465,58 @@ def interactive(
1046
1465
 
1047
1466
  # Handle special commands
1048
1467
  if user_input.startswith('/file '):
1049
- # Load and send file immediately
1468
+ # Load and send file immediately with retry option
1050
1469
  file_path_str = user_input[6:].strip()
1051
1470
  file_path = Path(file_path_str).expanduser()
1052
1471
 
1053
- file_content = load_file_content(file_path, warn_large=True)
1472
+ file_content = load_file_content(file_path, warn_large=True, check_vision=True)
1473
+
1474
+ # If file not found, offer retry
1475
+ if not file_content and not file_path.exists():
1476
+ choice = Prompt.ask(
1477
+ "[cyan]Enter 1 to retry file path or 2 to enter message[/cyan]",
1478
+ choices=["1", "2"],
1479
+ default="2"
1480
+ )
1481
+ if choice == "1":
1482
+ # Retry - prompt for new path
1483
+ new_path_str = Prompt.ask("File path")
1484
+ new_file_path = Path(new_path_str.strip()).expanduser()
1485
+ file_content = load_file_content(new_file_path, warn_large=True, check_vision=True)
1486
+ if file_content:
1487
+ file_path = new_file_path
1488
+ # If choice == "2", just continue to message prompt
1489
+
1054
1490
  if file_content:
1055
1491
  # Send file content as user message
1056
1492
  user_input = f"[File: {file_path.name}]\n\n{file_content}"
1057
1493
  messages.append(Message(role="user", content=user_input))
1058
1494
  else:
1059
- continue # Error already displayed, skip to next input
1495
+ continue # Skip to next input
1060
1496
 
1061
1497
  elif user_input.startswith('/attach '):
1062
- # Stage file for next message
1498
+ # Stage file for next message with retry option
1063
1499
  file_path_str = user_input[8:].strip()
1064
1500
  file_path = Path(file_path_str).expanduser()
1065
1501
 
1066
- file_content = load_file_content(file_path, warn_large=True)
1502
+ file_content = load_file_content(file_path, warn_large=True, check_vision=True)
1503
+
1504
+ # If file not found, offer retry
1505
+ if not file_content and not file_path.exists():
1506
+ choice = Prompt.ask(
1507
+ "[cyan]Enter 1 to retry file path or 2 to enter message[/cyan]",
1508
+ choices=["1", "2"],
1509
+ default="2"
1510
+ )
1511
+ if choice == "1":
1512
+ # Retry - prompt for new path
1513
+ new_path_str = Prompt.ask("File path")
1514
+ new_file_path = Path(new_path_str.strip()).expanduser()
1515
+ file_content = load_file_content(new_file_path, warn_large=True, check_vision=True)
1516
+ if file_content:
1517
+ file_path = new_file_path
1518
+ # If choice == "2", continue to message prompt (don't stage anything)
1519
+
1067
1520
  if file_content:
1068
1521
  staged_file_content = file_content
1069
1522
  staged_file_name = file_path.name
@@ -1163,19 +1616,18 @@ def interactive(
1163
1616
  console.print("[yellow]Provider not changed[/yellow]")
1164
1617
  continue
1165
1618
 
1166
- # Validate and display ALL models for the selected provider
1619
+ # Validate and display curated models for the selected provider
1167
1620
  from stratifyai.utils.provider_validator import get_validated_interactive_models
1168
1621
 
1169
1622
  with console.status(f"[cyan]Validating {new_provider} models...", spinner="dots"):
1170
- validation_data = get_validated_interactive_models(new_provider, all_models=True)
1623
+ validation_data = get_validated_interactive_models(new_provider)
1171
1624
 
1172
1625
  validation_result = validation_data["validation_result"]
1173
1626
  validated_models = validation_data["models"]
1174
1627
 
1175
1628
  # Show validation result
1176
1629
  if validation_result["error"]:
1177
- console.print(f"[yellow]⚠ {validation_result['error']}[/yellow]")
1178
- console.print("[dim]Showing all models (availability not confirmed)[/dim]")
1630
+ console.print("[yellow]⚠ Default models displayed. Could not validate models.[/yellow]")
1179
1631
  # Fall back to MODEL_CATALOG if validation fails
1180
1632
  available_models = list(MODEL_CATALOG[new_provider].keys())
1181
1633
  model_metadata = MODEL_CATALOG[new_provider]
@@ -1184,15 +1636,27 @@ def interactive(
1184
1636
  available_models = list(validated_models.keys())
1185
1637
  model_metadata = validated_models
1186
1638
 
1187
- # Show available models for new provider
1188
- console.print(f"\n[bold cyan]Current valid models for {new_provider}:[/bold cyan]")
1639
+ # Show available models for new provider with full metadata
1640
+ console.print(f"\n[bold cyan]Available {new_provider} models:[/bold cyan]")
1641
+
1642
+ # Display with friendly names, descriptions, and categories (same as initial selection)
1643
+ current_category = None
1189
1644
  for i, m in enumerate(available_models, 1):
1190
1645
  meta = model_metadata.get(m, {})
1191
- is_reasoning = meta.get("reasoning_model", False)
1646
+ display_name = meta.get("display_name", m)
1647
+ description = meta.get("description", "")
1648
+ category = meta.get("category", "")
1649
+
1650
+ # Show category header if changed
1651
+ if category and category != current_category:
1652
+ console.print(f" [dim]── {category} ──[/dim]")
1653
+ current_category = category
1654
+
1655
+ # Build label with current marker
1192
1656
  current_marker = " [green](current)[/green]" if m == model and new_provider == provider else ""
1193
- label = f" {i}. {m}{current_marker}"
1194
- if is_reasoning:
1195
- label += " [yellow](reasoning)[/yellow]"
1657
+ label = f" {i}. {display_name}{current_marker}"
1658
+ if description:
1659
+ label += f" [dim]- {description}[/dim]"
1196
1660
  console.print(label)
1197
1661
 
1198
1662
  # Get model selection
@@ -1220,6 +1684,43 @@ def interactive(
1220
1684
  # Update provider and model
1221
1685
  provider = new_provider
1222
1686
  model = new_model
1687
+
1688
+ # Check if new model has fixed temperature and prompt if needed
1689
+ new_model_info = MODEL_CATALOG.get(provider, {}).get(model, {})
1690
+ fixed_temp = new_model_info.get("fixed_temperature")
1691
+
1692
+ if fixed_temp is not None:
1693
+ temperature = fixed_temp
1694
+ console.print(f"\n[dim]Using fixed temperature: {fixed_temp} for this model[/dim]")
1695
+ else:
1696
+ # Retry loop for temperature input
1697
+ max_attempts = 3
1698
+ temperature = None
1699
+ for attempt in range(max_attempts):
1700
+ temp_input = Prompt.ask(
1701
+ "\n[bold cyan]Temperature[/bold cyan] (0.0-2.0, default 0.7)",
1702
+ default="0.7"
1703
+ )
1704
+
1705
+ try:
1706
+ temp_value = float(temp_input)
1707
+ if 0.0 <= temp_value <= 2.0:
1708
+ temperature = temp_value
1709
+ break
1710
+ else:
1711
+ console.print("[red]✗ Out of range.[/red] Temperature must be between 0.0 and 2.0")
1712
+ if attempt < max_attempts - 1:
1713
+ console.print("[dim]Try again...[/dim]")
1714
+ except ValueError:
1715
+ console.print(f"[red]✗ Invalid input.[/red] Please enter a number (e.g., '0.7' not '{temp_input}')")
1716
+ if attempt < max_attempts - 1:
1717
+ console.print("[dim]Try again...[/dim]")
1718
+
1719
+ # If still no valid temperature after retries, use default
1720
+ if temperature is None:
1721
+ console.print("[yellow]Too many invalid attempts. Using default: 0.7[/yellow]")
1722
+ temperature = 0.7
1723
+
1223
1724
  client = LLMClient(provider=provider) # Reinitialize client
1224
1725
 
1225
1726
  # Update context window info
@@ -1317,7 +1818,7 @@ def interactive(
1317
1818
  console.print(f"[yellow]⚠ Conversation history truncated (estimated {estimated_tokens:,} tokens)[/yellow]")
1318
1819
 
1319
1820
  # Create request and get response
1320
- request = ChatRequest(model=model, messages=messages)
1821
+ request = ChatRequest(model=model, messages=messages, temperature=temperature)
1321
1822
 
1322
1823
  try:
1323
1824
  # Show spinner while waiting for response
@@ -1330,9 +1831,9 @@ def interactive(
1330
1831
  # Store last response for /save command
1331
1832
  last_response = response
1332
1833
 
1333
- # Display metadata and response
1334
- console.print(f"\n[bold green]Assistant[/bold green]")
1335
- console.print(f"[bold]Provider:[/bold] [cyan]{provider}[/cyan] | [bold]Model:[/bold] [cyan]{model}[/cyan]")
1834
+ # Display metadata and response (interactive mode - cyan)
1835
+ console.print(f"\n[{INTERACTIVE_ACCENT}]⚡ Assistant[/{INTERACTIVE_ACCENT}]")
1836
+ console.print(f"[bold]Provider:[/bold] [{INTERACTIVE_COLOR}]{provider}[/{INTERACTIVE_COLOR}] | [bold]Model:[/bold] [{INTERACTIVE_COLOR}]{model}[/{INTERACTIVE_COLOR}]")
1336
1837
 
1337
1838
  # Build usage line with token breakdown and cache info
1338
1839
  usage_parts = [
@@ -1356,7 +1857,7 @@ def interactive(
1356
1857
  usage_parts.append(f"Cache Read: {response.usage.cache_read_tokens:,}")
1357
1858
 
1358
1859
  console.print(f"[dim]{' | '.join(usage_parts)}[/dim]")
1359
- console.print(f"\n{response.content}", style="cyan")
1860
+ console.print(f"\n{response.content}", style=INTERACTIVE_COLOR)
1360
1861
  console.print("[dim]💡 Tip: Use /save to save this response to a file[/dim]\n")
1361
1862
 
1362
1863
  except AuthenticationError as e: