chat-console 0.2.8__py3-none-any.whl → 0.2.98__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.
app/main.py CHANGED
@@ -5,15 +5,35 @@ Simplified version of Chat CLI with AI functionality
5
5
  import os
6
6
  import asyncio
7
7
  import typer
8
+ import logging
8
9
  from typing import List, Optional, Callable, Awaitable
9
10
  from datetime import datetime
10
11
 
12
+ # Create a dedicated logger that definitely writes to a file
13
+ log_dir = os.path.expanduser("~/.cache/chat-cli")
14
+ os.makedirs(log_dir, exist_ok=True)
15
+ debug_log_file = os.path.join(log_dir, "debug.log")
16
+
17
+ # Configure the logger
18
+ file_handler = logging.FileHandler(debug_log_file)
19
+ file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
20
+
21
+ # Get the logger and add the handler
22
+ debug_logger = logging.getLogger("chat-cli-debug")
23
+ debug_logger.setLevel(logging.DEBUG)
24
+ debug_logger.addHandler(file_handler)
25
+
26
+ # Add a convenience function to log to this file
27
+ def debug_log(message):
28
+ debug_logger.info(message)
29
+
11
30
  from textual.app import App, ComposeResult
12
31
  from textual.containers import Container, Horizontal, Vertical, ScrollableContainer, Center
13
32
  from textual.reactive import reactive
14
33
  from textual.widgets import Button, Input, Label, Static, Header, Footer, ListView, ListItem
15
34
  from textual.binding import Binding
16
35
  from textual import work, log, on
36
+ from textual.worker import Worker, WorkerState # Import Worker class and WorkerState enum
17
37
  from textual.screen import Screen
18
38
  from openai import OpenAI
19
39
  from app.models import Message, Conversation
@@ -25,7 +45,7 @@ from app.ui.model_selector import ModelSelector, StyleSelector
25
45
  from app.ui.chat_list import ChatList
26
46
  from app.ui.model_browser import ModelBrowser
27
47
  from app.api.base import BaseModelClient
28
- from app.utils import generate_streaming_response, save_settings_to_config, generate_conversation_title # Import title function
48
+ from app.utils import generate_streaming_response, save_settings_to_config, generate_conversation_title, resolve_model_id # Import resolver
29
49
  # Import version here to avoid potential circular import issues at top level
30
50
  from app import __version__
31
51
 
@@ -211,11 +231,17 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
211
231
  color: $text;
212
232
  content-align: center middle;
213
233
  text-align: center;
234
+ text-style: bold;
214
235
  }
215
236
 
216
237
  #loading-indicator.hidden { # Keep SimpleChatApp CSS
217
238
  display: none;
218
239
  }
240
+
241
+ #loading-indicator.model-loading {
242
+ background: $warning;
243
+ color: $text;
244
+ }
219
245
 
220
246
  #input-area { # Keep SimpleChatApp CSS
221
247
  width: 100%; # Keep SimpleChatApp CSS
@@ -313,12 +339,16 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
313
339
  current_conversation = reactive(None) # Keep SimpleChatApp reactive var
314
340
  is_generating = reactive(False) # Keep SimpleChatApp reactive var
315
341
  current_generation_task: Optional[asyncio.Task] = None # Add task reference
342
+ _loading_frame = 0 # Track animation frame
343
+ _loading_animation_task: Optional[asyncio.Task] = None # Animation task
316
344
 
317
345
  def __init__(self, initial_text: Optional[str] = None): # Keep SimpleChatApp __init__
318
346
  super().__init__() # Keep SimpleChatApp __init__
319
347
  self.db = ChatDatabase() # Keep SimpleChatApp __init__
320
348
  self.messages = [] # Keep SimpleChatApp __init__
321
- self.selected_model = CONFIG["default_model"] # Keep SimpleChatApp __init__
349
+ # Resolve the default model ID on initialization
350
+ default_model_from_config = CONFIG["default_model"]
351
+ self.selected_model = resolve_model_id(default_model_from_config)
322
352
  self.selected_style = CONFIG["default_style"] # Keep SimpleChatApp __init__
323
353
  self.initial_text = initial_text # Keep SimpleChatApp __init__
324
354
  # Removed self.input_widget instance variable
@@ -347,7 +377,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
347
377
  pass
348
378
 
349
379
  # Loading indicator
350
- yield Static("Generating response...", id="loading-indicator", classes="hidden")
380
+ yield Static("▪▪▪ Generating response...", id="loading-indicator", classes="hidden", markup=False)
351
381
 
352
382
  # Input area
353
383
  with Container(id="input-area"):
@@ -477,30 +507,54 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
477
507
  log("Attempting to cancel generation task")
478
508
  if self.current_generation_task and not self.current_generation_task.done():
479
509
  log("Cancelling active generation task.")
480
- # Get the client for the current model
510
+
511
+ # Get the client for the current model first and cancel the connection
481
512
  try:
482
513
  model = self.selected_model
483
514
  client = BaseModelClient.get_client_for_model(model)
484
- # Call the client's cancel method if it's an Ollama client
515
+
516
+ # Call the client's cancel method if it's supported
485
517
  if hasattr(client, 'cancel_stream'):
486
518
  log("Calling client.cancel_stream() to terminate API session")
487
- await client.cancel_stream()
519
+ try:
520
+ # This will close the HTTP connection to Ollama server
521
+ await client.cancel_stream()
522
+ log("Client stream cancelled successfully")
523
+ except Exception as e:
524
+ log.error(f"Error in client.cancel_stream(): {str(e)}")
488
525
  except Exception as e:
489
- log.error(f"Error cancelling client stream: {str(e)}")
526
+ log.error(f"Error setting up client cancellation: {str(e)}")
490
527
 
491
- # Now cancel the asyncio task
492
- self.current_generation_task.cancel()
493
- # The finally block in generate_response will handle is_generating = False and UI updates
494
- self.notify("Stopping generation...", severity="warning", timeout=2) # Notify user immediately
528
+ # Now cancel the asyncio task - this should raise CancelledError in the task
529
+ try:
530
+ log("Cancelling asyncio task")
531
+ self.current_generation_task.cancel()
532
+ # Give a moment for cancellation to propagate
533
+ await asyncio.sleep(0.1)
534
+ log(f"Task cancelled. Task done: {self.current_generation_task.done()}")
535
+ except Exception as e:
536
+ log.error(f"Error cancelling task: {str(e)}")
537
+
538
+ # Notify user that we're stopping
539
+ self.notify("Stopping generation...", severity="warning", timeout=2)
495
540
  else:
496
- # This case might happen if is_generating is True, but no active task found to cancel. Resetting flag.")
497
- self.is_generating = False # Reset flag manually if task is missing
541
+ # This happens if is_generating is True, but no active task found to cancel
542
+ log("No active generation task found, but is_generating=True. Resetting state.")
543
+ self.is_generating = False
544
+
545
+ # Make sure to cancel animation task too
546
+ if self._loading_animation_task and not self._loading_animation_task.done():
547
+ try:
548
+ self._loading_animation_task.cancel()
549
+ except Exception as e:
550
+ log.error(f"Error cancelling animation task: {str(e)}")
551
+ self._loading_animation_task = None
552
+
498
553
  loading = self.query_one("#loading-indicator")
499
554
  loading.add_class("hidden")
500
555
  else:
501
556
  log("Escape pressed, but settings not visible and not actively generating.")
502
- # Optionally add other escape behaviors here if needed for the main screen
503
- # e.g., clear input, deselect item, etc.
557
+ # Optionally add other escape behaviors here if needed
504
558
 
505
559
  def update_app_info(self) -> None:
506
560
  """Update the displayed app information."""
@@ -522,20 +576,25 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
522
576
  pass
523
577
 
524
578
  async def update_messages_ui(self) -> None: # Keep SimpleChatApp update_messages_ui
525
- """Update the messages UI.""" # Keep SimpleChatApp update_messages_ui docstring
579
+ """Update the messages UI with improved stability.""" # Keep SimpleChatApp update_messages_ui docstring
526
580
  # Clear existing messages # Keep SimpleChatApp update_messages_ui
527
581
  messages_container = self.query_one("#messages-container") # Keep SimpleChatApp update_messages_ui
528
582
  messages_container.remove_children() # Keep SimpleChatApp update_messages_ui
529
583
 
530
- # Add messages with a small delay between each # Keep SimpleChatApp update_messages_ui
584
+ # Batch add all messages first without scrolling or refreshing between each mount
585
+ # This avoids unnecessary layout shifts while adding messages
531
586
  for message in self.messages: # Keep SimpleChatApp update_messages_ui
532
587
  display = MessageDisplay(message, highlight_code=CONFIG["highlight_code"]) # Keep SimpleChatApp update_messages_ui
533
588
  messages_container.mount(display) # Keep SimpleChatApp update_messages_ui
534
- messages_container.scroll_end(animate=False) # Keep SimpleChatApp update_messages_ui
535
- await asyncio.sleep(0.01) # Small delay to prevent UI freezing # Keep SimpleChatApp update_messages_ui
536
-
537
- # Final scroll to bottom # Keep SimpleChatApp update_messages_ui
589
+
590
+ # Perform a single refresh and scroll after mounting all messages
591
+ # This significantly reduces the visual bouncing effect
592
+ # A small delay before scrolling helps ensure stable layout
593
+ await asyncio.sleep(0.05) # Single delay after all messages are mounted
538
594
  messages_container.scroll_end(animate=False) # Keep SimpleChatApp update_messages_ui
595
+
596
+ # Use layout=False refresh if possible to further reduce bouncing
597
+ self.refresh(layout=False)
539
598
 
540
599
  async def on_input_submitted(self, event: Input.Submitted) -> None: # Keep SimpleChatApp on_input_submitted
541
600
  """Handle input submission (Enter key in the main input).""" # Keep SimpleChatApp on_input_submitted docstring
@@ -573,6 +632,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
573
632
  # If this is the first message and dynamic titles are enabled, generate one
574
633
  if is_first_message and self.current_conversation and CONFIG.get("generate_dynamic_titles", True):
575
634
  log("First message detected, generating title...")
635
+ debug_log("First message detected, attempting to generate conversation title")
576
636
  title_generation_in_progress = True # Use a local flag
577
637
  loading = self.query_one("#loading-indicator")
578
638
  loading.remove_class("hidden") # Show loading for title gen
@@ -580,13 +640,71 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
580
640
  try:
581
641
  # Get appropriate client
582
642
  model = self.selected_model
643
+ debug_log(f"Selected model for title generation: '{model}'")
644
+
645
+ # Check if model is valid
646
+ if not model:
647
+ debug_log("Model is empty, falling back to default")
648
+ # Fallback to a safe default model - preferring OpenAI if key exists
649
+ if OPENAI_API_KEY:
650
+ model = "gpt-3.5-turbo"
651
+ debug_log("Falling back to OpenAI gpt-3.5-turbo for title generation")
652
+ elif ANTHROPIC_API_KEY:
653
+ model = "claude-instant-1.2"
654
+ debug_log("Falling back to Anthropic claude-instant-1.2 for title generation")
655
+ else:
656
+ # Last resort - check for a common Ollama model
657
+ try:
658
+ from app.api.ollama import OllamaClient
659
+ ollama = OllamaClient()
660
+ models = await ollama.get_available_models()
661
+ if models and len(models) > 0:
662
+ debug_log(f"Found {len(models)} Ollama models, using first one")
663
+ model = models[0].get("id", "llama3")
664
+ else:
665
+ model = "llama3" # Common default
666
+ debug_log(f"Falling back to Ollama model: {model}")
667
+ except Exception as ollama_err:
668
+ debug_log(f"Error getting Ollama models: {str(ollama_err)}")
669
+ model = "llama3" # Final fallback
670
+ debug_log("Final fallback to llama3")
671
+
672
+ debug_log(f"Getting client for model: {model}")
583
673
  client = BaseModelClient.get_client_for_model(model)
674
+
584
675
  if client is None:
585
- raise Exception(f"No client available for model: {model}")
676
+ debug_log(f"No client available for model: {model}, trying to initialize")
677
+ # Try to determine client type and initialize manually
678
+ client_type = BaseModelClient.get_client_type_for_model(model)
679
+ if client_type:
680
+ debug_log(f"Found client type {client_type.__name__} for {model}, initializing")
681
+ try:
682
+ client = client_type()
683
+ debug_log("Client initialized successfully")
684
+ except Exception as init_err:
685
+ debug_log(f"Error initializing client: {str(init_err)}")
686
+
687
+ if client is None:
688
+ debug_log("Could not initialize client, falling back to safer model")
689
+ # Try a different model as last resort
690
+ if OPENAI_API_KEY:
691
+ from app.api.openai import OpenAIClient
692
+ client = OpenAIClient()
693
+ model = "gpt-3.5-turbo"
694
+ debug_log("Falling back to OpenAI for title generation")
695
+ elif ANTHROPIC_API_KEY:
696
+ from app.api.anthropic import AnthropicClient
697
+ client = AnthropicClient()
698
+ model = "claude-instant-1.2"
699
+ debug_log("Falling back to Anthropic for title generation")
700
+ else:
701
+ raise Exception("No valid API clients available for title generation")
586
702
 
587
703
  # Generate title
588
704
  log(f"Calling generate_conversation_title with model: {model}")
705
+ debug_log(f"Calling generate_conversation_title with model: {model}")
589
706
  title = await generate_conversation_title(content, model, client)
707
+ debug_log(f"Generated title: {title}")
590
708
  log(f"Generated title: {title}")
591
709
 
592
710
  # Update conversation title in database
@@ -601,10 +719,17 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
601
719
 
602
720
  # Update conversation object
603
721
  self.current_conversation.title = title
722
+
723
+ # IMPORTANT: Save the successful model for consistency
724
+ # If the title was generated with a different model than initially selected,
725
+ # update the selected_model to match so the response uses the same model
726
+ debug_log(f"Using same model for chat response: '{model}'")
727
+ self.selected_model = model
604
728
 
605
729
  self.notify(f"Conversation title set to: {title}", severity="information", timeout=3)
606
730
 
607
731
  except Exception as e:
732
+ debug_log(f"Failed to generate title: {str(e)}")
608
733
  log.error(f"Failed to generate title: {str(e)}")
609
734
  self.notify(f"Failed to generate title: {str(e)}", severity="warning")
610
735
  finally:
@@ -613,7 +738,13 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
613
738
  # This check might be redundant if generate_response always shows it anyway
614
739
  if not self.is_generating:
615
740
  loading.add_class("hidden")
616
-
741
+
742
+ # Small delay to ensure state is updated
743
+ await asyncio.sleep(0.1)
744
+
745
+ # Log just before generate_response call
746
+ debug_log(f"About to call generate_response with model: '{self.selected_model}'")
747
+
617
748
  # Generate AI response (will set self.is_generating and handle loading indicator)
618
749
  await self.generate_response()
619
750
 
@@ -622,39 +753,148 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
622
753
 
623
754
  async def generate_response(self) -> None:
624
755
  """Generate an AI response using a non-blocking worker."""
756
+ # Import debug_log function from main
757
+ debug_log(f"Entering generate_response method")
758
+
625
759
  if not self.current_conversation or not self.messages:
760
+ debug_log("No current conversation or messages, returning")
626
761
  return
627
762
 
628
763
  self.is_generating = True
629
764
  log("Setting is_generating to True")
765
+ debug_log("Setting is_generating to True")
630
766
  loading = self.query_one("#loading-indicator")
631
767
  loading.remove_class("hidden")
768
+
769
+ # For Ollama models, show the loading indicator immediately
770
+ from app.api.ollama import OllamaClient
771
+ debug_log(f"Current selected model: '{self.selected_model}'")
772
+ client_type = BaseModelClient.get_client_type_for_model(self.selected_model)
773
+ debug_log(f"Client type: {client_type.__name__ if client_type else 'None'}")
774
+
775
+ if self.selected_model and client_type == OllamaClient:
776
+ log("Ollama model detected, showing immediate loading indicator")
777
+ debug_log("Ollama model detected, showing immediate loading indicator")
778
+ loading.add_class("model-loading")
779
+ # Update the loading indicator text directly
780
+ loading.update("⚙️ Preparing Ollama model...")
781
+ else:
782
+ loading.remove_class("model-loading")
783
+ # Start with a simple animation pattern that won't cause markup issues
784
+ self._loading_frame = 0
785
+ # Stop any existing animation task
786
+ if self._loading_animation_task and not self._loading_animation_task.done():
787
+ self._loading_animation_task.cancel()
788
+ # Start the animation
789
+ self._loading_animation_task = asyncio.create_task(self._animate_loading_task(loading))
632
790
 
633
791
  try:
634
792
  # Get conversation parameters
635
- model = self.selected_model
793
+ # Ensure the model ID is resolved before passing to the API client
794
+ unresolved_model = self.selected_model
795
+ model = resolve_model_id(unresolved_model)
796
+ log(f"Using model for generation: {model} (Resolved from: {unresolved_model})")
636
797
  style = self.selected_style
637
-
638
- # Convert messages to API format
798
+
799
+ debug_log(f"Using model: '{model}', style: '{style}'")
800
+
801
+ # Ensure we have a valid model
802
+ if not model:
803
+ debug_log("Model is empty, selecting a default model")
804
+ # Same fallback logic as in autotitling - this ensures consistency
805
+ if OPENAI_API_KEY:
806
+ model = "gpt-3.5-turbo"
807
+ debug_log("Falling back to OpenAI gpt-3.5-turbo")
808
+ elif ANTHROPIC_API_KEY:
809
+ model = "claude-instant-1.2"
810
+ debug_log("Falling back to Anthropic claude-instant-1.2")
811
+ else:
812
+ # Check for a common Ollama model
813
+ try:
814
+ ollama = OllamaClient()
815
+ models = await ollama.get_available_models()
816
+ if models and len(models) > 0:
817
+ debug_log(f"Found {len(models)} Ollama models, using first one")
818
+ model = models[0].get("id", "llama3")
819
+ else:
820
+ model = "llama3" # Common default
821
+ debug_log(f"Falling back to Ollama model: {model}")
822
+ except Exception as ollama_err:
823
+ debug_log(f"Error getting Ollama models: {str(ollama_err)}")
824
+ model = "llama3" # Final fallback
825
+ debug_log("Final fallback to llama3")
826
+
827
+ # Convert messages to API format with enhanced error checking
639
828
  api_messages = []
640
- for msg in self.messages:
641
- api_messages.append({
642
- "role": msg.role,
643
- "content": msg.content
644
- })
829
+ debug_log(f"Converting {len(self.messages)} messages to API format")
830
+
831
+ for i, msg in enumerate(self.messages):
832
+ try:
833
+ debug_log(f"Processing message {i}: type={type(msg).__name__}, dir={dir(msg)}")
834
+ debug_log(f"Adding message to API format: role={msg.role}, content_len={len(msg.content)}")
835
+
836
+ # Create a fully validated message dict
837
+ message_dict = {
838
+ "role": msg.role if hasattr(msg, 'role') and msg.role else "user",
839
+ "content": msg.content if hasattr(msg, 'content') and msg.content else ""
840
+ }
841
+
842
+ api_messages.append(message_dict)
843
+ debug_log(f"Successfully added message {i}")
844
+ except Exception as e:
845
+ debug_log(f"Error adding message {i} to API format: {str(e)}")
846
+ # Create a safe fallback message
847
+ fallback_msg = {
848
+ "role": "user",
849
+ "content": str(msg) if msg is not None else "Error retrieving message content"
850
+ }
851
+ api_messages.append(fallback_msg)
852
+ debug_log(f"Added fallback message for {i}")
853
+
854
+ debug_log(f"Prepared {len(api_messages)} messages for API")
645
855
 
646
856
  # Get appropriate client
857
+ debug_log(f"Getting client for model: {model}")
647
858
  try:
648
859
  client = BaseModelClient.get_client_for_model(model)
860
+ debug_log(f"Client: {client.__class__.__name__ if client else 'None'}")
861
+
649
862
  if client is None:
650
- raise Exception(f"No client available for model: {model}")
863
+ debug_log(f"No client available for model: {model}, trying to initialize")
864
+ # Try to determine client type and initialize manually
865
+ client_type = BaseModelClient.get_client_type_for_model(model)
866
+ if client_type:
867
+ debug_log(f"Found client type {client_type.__name__} for {model}, initializing")
868
+ try:
869
+ client = client_type()
870
+ debug_log(f"Successfully initialized {client_type.__name__}")
871
+ except Exception as init_err:
872
+ debug_log(f"Error initializing client: {str(init_err)}")
873
+
874
+ if client is None:
875
+ debug_log("Could not initialize client, falling back to safer model")
876
+ # Try a different model as last resort
877
+ if OPENAI_API_KEY:
878
+ from app.api.openai import OpenAIClient
879
+ client = OpenAIClient()
880
+ model = "gpt-3.5-turbo"
881
+ debug_log("Falling back to OpenAI client")
882
+ elif ANTHROPIC_API_KEY:
883
+ from app.api.anthropic import AnthropicClient
884
+ client = AnthropicClient()
885
+ model = "claude-instant-1.2"
886
+ debug_log("Falling back to Anthropic client")
887
+ else:
888
+ raise Exception("No valid API clients available")
651
889
  except Exception as e:
890
+ debug_log(f"Failed to initialize model client: {str(e)}")
652
891
  self.notify(f"Failed to initialize model client: {str(e)}", severity="error")
653
892
  self.is_generating = False
654
893
  loading.add_class("hidden")
655
894
  return
656
895
 
657
896
  # Start streaming response
897
+ debug_log("Creating assistant message with 'Thinking...'")
658
898
  assistant_message = Message(role="assistant", content="Thinking...")
659
899
  self.messages.append(assistant_message)
660
900
  messages_container = self.query_one("#messages-container")
@@ -669,94 +909,191 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
669
909
  update_lock = asyncio.Lock()
670
910
 
671
911
  async def update_ui(content: str):
912
+ # This function remains the same, called by the worker
672
913
  if not self.is_generating:
673
- log("update_ui called but is_generating is False, returning.")
914
+ debug_log("update_ui called but is_generating is False, returning.")
674
915
  return
675
916
 
676
917
  async with update_lock:
677
918
  try:
678
919
  # Clear thinking indicator on first content
679
920
  if assistant_message.content == "Thinking...":
921
+ debug_log("First content received, clearing 'Thinking...'")
680
922
  assistant_message.content = ""
681
923
 
682
- # Update message with full content so far
924
+ # Update the message object with the full content
683
925
  assistant_message.content = content
684
- # Update UI with full content
926
+
927
+ # Update UI with the content - this no longer triggers refresh itself
685
928
  await message_display.update_content(content)
686
- # Force a refresh and scroll
687
- self.refresh(layout=True)
688
- await asyncio.sleep(0.05) # Longer delay for UI stability
689
- messages_container.scroll_end(animate=False)
690
- # Force another refresh to ensure content is visible
691
- self.refresh(layout=True)
929
+
930
+ # Throttle UI updates to reduce visual jitter and improve performance
931
+ # Only refresh visually every ~5 tokens (estimated by content length changes)
932
+ content_length = len(content)
933
+ do_refresh = (
934
+ content_length < 20 or # Always refresh for the first few tokens
935
+ content_length % 16 == 0 or # Then periodically
936
+ content.endswith("\n") # And on newlines
937
+ )
938
+
939
+ if do_refresh:
940
+ # Only scroll without full layout recalculation
941
+ messages_container.scroll_end(animate=False)
942
+ # Light refresh without full layout recalculation
943
+ self.refresh(layout=False)
692
944
  except Exception as e:
945
+ debug_log(f"Error updating UI: {str(e)}")
693
946
  log.error(f"Error updating UI: {str(e)}")
694
947
 
695
- # Define worker for background processing
696
- @work(exit_on_error=True)
697
- async def run_generation_worker():
698
- try:
699
- # Generate the response in background
700
- full_response = await generate_streaming_response(
701
- self,
702
- api_messages,
703
- model,
704
- style,
705
- client,
706
- update_ui
707
- )
708
-
709
- # Save complete response to database
710
- if self.is_generating and full_response:
711
- log("Generation completed normally, saving to database")
712
- self.db.add_message(
713
- self.current_conversation.id,
714
- "assistant",
715
- full_response
716
- )
717
-
718
- # Final UI refresh
719
- self.refresh(layout=True)
720
-
721
- except asyncio.CancelledError:
722
- log.warning("Generation worker was cancelled")
723
- # Remove the incomplete message
724
- if self.messages and self.messages[-1].role == "assistant":
725
- self.messages.pop()
726
- await self.update_messages_ui()
727
- self.notify("Generation stopped by user", severity="warning", timeout=2)
728
-
729
- except Exception as e:
730
- log.error(f"Error in generation worker: {str(e)}")
731
- self.notify(f"Generation error: {str(e)}", severity="error", timeout=5)
732
- # Add error message to UI
733
- if self.messages and self.messages[-1].role == "assistant":
734
- self.messages.pop() # Remove thinking message
735
- error_msg = f"Error: {str(e)}"
736
- self.messages.append(Message(role="assistant", content=error_msg))
737
- await self.update_messages_ui()
738
-
739
- finally:
740
- # Always clean up state and UI
741
- log("Generation worker completed, resetting state")
742
- self.is_generating = False
743
- self.current_generation_task = None
744
- loading = self.query_one("#loading-indicator")
745
- loading.add_class("hidden")
746
- self.refresh(layout=True)
747
- self.query_one("#message-input").focus()
748
-
749
- # Start the worker and keep a reference to it
750
- worker = run_generation_worker()
948
+ # --- Remove the inner run_generation_worker function ---
949
+
950
+ # Start the worker directly using the imported function
951
+ debug_log("Starting generate_streaming_response worker")
952
+ # Call the @work decorated function directly
953
+ worker = generate_streaming_response(
954
+ self,
955
+ api_messages,
956
+ model,
957
+ style,
958
+ client,
959
+ update_ui # Pass the callback function
960
+ )
751
961
  self.current_generation_task = worker
752
-
962
+ # Worker completion will be handled by on_worker_state_changed
963
+
753
964
  except Exception as e:
754
- log.error(f"Error setting up generation: {str(e)}")
965
+ # This catches errors during the *setup* before the worker starts
966
+ debug_log(f"Error setting up generation worker: {str(e)}")
967
+ log.error(f"Error setting up generation worker: {str(e)}")
755
968
  self.notify(f"Error: {str(e)}", severity="error")
969
+ # Ensure cleanup if setup fails
970
+ self.is_generating = False # Reset state
971
+ self.current_generation_task = None
972
+ if self._loading_animation_task and not self._loading_animation_task.done():
973
+ self._loading_animation_task.cancel()
974
+ self._loading_animation_task = None
975
+ try:
976
+ loading = self.query_one("#loading-indicator")
977
+ loading.add_class("hidden")
978
+ self.query_one("#message-input").focus()
979
+ except Exception:
980
+ pass # Ignore UI errors during cleanup
981
+
982
+ # Rename this method slightly to avoid potential conflicts and clarify purpose
983
+ async def _handle_generation_result(self, worker: Worker[Optional[str]]) -> None:
984
+ """Handles the result of the generation worker (success, error, cancelled)."""
985
+ # Import debug_log again for safety within this callback context
986
+ try:
987
+ from app.main import debug_log
988
+ except ImportError:
989
+ debug_log = lambda msg: None
990
+
991
+ debug_log(f"Generation worker completed. State: {worker.state}")
992
+
993
+ try:
994
+ if worker.state == "cancelled":
995
+ debug_log("Generation worker was cancelled")
996
+ log.warning("Generation worker was cancelled")
997
+ # Remove the incomplete message
998
+ if self.messages and self.messages[-1].role == "assistant":
999
+ debug_log("Removing incomplete assistant message")
1000
+ self.messages.pop()
1001
+ await self.update_messages_ui()
1002
+ self.notify("Generation stopped by user", severity="warning", timeout=2)
1003
+
1004
+ elif worker.state == "error":
1005
+ error = worker.error
1006
+ debug_log(f"Error in generation worker: {error}")
1007
+ log.error(f"Error in generation worker: {error}")
1008
+ self.notify(f"Generation error: {error}", severity="error", timeout=5)
1009
+ # Add error message to UI
1010
+ if self.messages and self.messages[-1].role == "assistant":
1011
+ debug_log("Removing thinking message")
1012
+ self.messages.pop() # Remove thinking message
1013
+ error_msg = f"Error: {error}"
1014
+ debug_log(f"Adding error message: {error_msg}")
1015
+ self.messages.append(Message(role="assistant", content=error_msg))
1016
+ await self.update_messages_ui()
1017
+
1018
+ elif worker.state == "success":
1019
+ full_response = worker.result
1020
+ debug_log("Generation completed normally, saving to database")
1021
+ log("Generation completed normally, saving to database")
1022
+ # Save complete response to database (check if response is valid)
1023
+ if full_response and isinstance(full_response, str):
1024
+ self.db.add_message(
1025
+ self.current_conversation.id,
1026
+ "assistant",
1027
+ full_response
1028
+ )
1029
+ # Update the final message object content (optional, UI should be up-to-date)
1030
+ if self.messages and self.messages[-1].role == "assistant":
1031
+ self.messages[-1].content = full_response
1032
+ else:
1033
+ debug_log("Worker finished successfully but response was empty or invalid.")
1034
+ # Handle case where 'Thinking...' might still be the last message
1035
+ if self.messages and self.messages[-1].role == "assistant" and self.messages[-1].content == "Thinking...":
1036
+ self.messages.pop() # Remove 'Thinking...' if no content arrived
1037
+ await self.update_messages_ui()
1038
+
1039
+ # Final UI refresh with minimal layout recalculation
1040
+ # Use layout=False to prevent UI jumping at the end
1041
+ self.refresh(layout=False)
1042
+ await asyncio.sleep(0.1) # Allow UI to stabilize
1043
+ messages_container = self.query_one("#messages-container")
1044
+ messages_container.scroll_end(animate=False)
1045
+
1046
+ except Exception as e:
1047
+ # Catch any unexpected errors during the callback itself
1048
+ debug_log(f"Error in on_generation_complete callback: {str(e)}")
1049
+ log.error(f"Error in on_generation_complete callback: {str(e)}")
1050
+ self.notify(f"Internal error handling response: {str(e)}", severity="error")
1051
+
1052
+ finally:
1053
+ # Always clean up state and UI, regardless of worker outcome
1054
+ debug_log("Cleaning up after generation worker")
756
1055
  self.is_generating = False
757
- loading = self.query_one("#loading-indicator")
758
- loading.add_class("hidden")
759
- self.query_one("#message-input").focus()
1056
+ self.current_generation_task = None
1057
+
1058
+ # Stop the animation task
1059
+ if self._loading_animation_task and not self._loading_animation_task.done():
1060
+ debug_log("Cancelling loading animation task")
1061
+ self._loading_animation_task.cancel()
1062
+ self._loading_animation_task = None
1063
+
1064
+ try:
1065
+ loading = self.query_one("#loading-indicator")
1066
+ loading.add_class("hidden")
1067
+ self.refresh(layout=True) # Refresh after hiding loading
1068
+ self.query_one("#message-input").focus()
1069
+ except Exception as ui_err:
1070
+ debug_log(f"Error during final UI cleanup: {str(ui_err)}")
1071
+ log.error(f"Error during final UI cleanup: {str(ui_err)}")
1072
+
1073
+ @on(Worker.StateChanged)
1074
+ async def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
1075
+ """Handle worker state changes."""
1076
+ # Import debug_log again for safety within this callback context
1077
+ try:
1078
+ from app.main import debug_log
1079
+ except ImportError:
1080
+ debug_log = lambda msg: None
1081
+
1082
+ debug_log(f"Worker {event.worker.name} state changed to {event.state}")
1083
+
1084
+ # Check if this is the generation worker we are tracking
1085
+ if event.worker is self.current_generation_task:
1086
+ # Check if the worker has reached a final state by comparing against enum values
1087
+ final_states = {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}
1088
+ if event.state in final_states:
1089
+ debug_log(f"Generation worker ({event.worker.name}) reached final state: {event.state}")
1090
+ # Call the handler function
1091
+ await self._handle_generation_result(event.worker)
1092
+ else:
1093
+ debug_log(f"Generation worker ({event.worker.name}) is in intermediate state: {event.state}")
1094
+ else:
1095
+ debug_log(f"State change event from unrelated worker: {event.worker.name}")
1096
+
760
1097
 
761
1098
  def on_model_selector_model_selected(self, event: ModelSelector.ModelSelected) -> None: # Keep SimpleChatApp on_model_selector_model_selected
762
1099
  """Handle model selection""" # Keep SimpleChatApp on_model_selector_model_selected docstring
@@ -847,8 +1184,23 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
847
1184
  await self.update_messages_ui() # Keep SimpleChatApp view_chat_history
848
1185
 
849
1186
  # Update model and style selectors # Keep SimpleChatApp view_chat_history
850
- self.selected_model = self.current_conversation.model # Keep SimpleChatApp view_chat_history
1187
+ # Resolve the model ID loaded from the conversation data
1188
+ loaded_model_id = self.current_conversation.model
1189
+ resolved_model_id = resolve_model_id(loaded_model_id)
1190
+ log(f"Loaded model ID from history: {loaded_model_id}, Resolved to: {resolved_model_id}")
1191
+
1192
+ self.selected_model = resolved_model_id # Use the resolved ID
851
1193
  self.selected_style = self.current_conversation.style # Keep SimpleChatApp view_chat_history
1194
+
1195
+ # Update settings panel selectors if they exist
1196
+ try:
1197
+ model_selector = self.query_one(ModelSelector)
1198
+ model_selector.set_selected_model(self.selected_model) # Use resolved ID here too
1199
+ style_selector = self.query_one(StyleSelector)
1200
+ style_selector.set_selected_style(self.selected_style)
1201
+ except Exception as e:
1202
+ log(f"Error updating selectors after history load: {e}")
1203
+
852
1204
  self.update_app_info() # Update info bar after loading history
853
1205
 
854
1206
  self.push_screen(HistoryScreen(conversations, handle_selection)) # Keep SimpleChatApp view_chat_history
@@ -864,6 +1216,53 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
864
1216
  """Open the Ollama model browser screen."""
865
1217
  # Always trigger regardless of focus
866
1218
  self.push_screen(ModelBrowserScreen())
1219
+
1220
+ async def _animate_loading_task(self, loading_widget: Static) -> None:
1221
+ """Animate the loading indicator with a simple text animation"""
1222
+ try:
1223
+ # Animation frames (simple text animation)
1224
+ frames = [
1225
+ "▪▫▫ Generating response...",
1226
+ "▪▪▫ Generating response...",
1227
+ "▪▪▪ Generating response...",
1228
+ "▫▪▪ Generating response...",
1229
+ "▫▫▪ Generating response...",
1230
+ "▫▫▫ Generating response..."
1231
+ ]
1232
+
1233
+ while self.is_generating:
1234
+ try:
1235
+ # Update the loading text with safety checks
1236
+ if frames and len(frames) > 0:
1237
+ frame_idx = self._loading_frame % len(frames)
1238
+ loading_widget.update(frames[frame_idx])
1239
+ else:
1240
+ # Fallback if frames is empty
1241
+ loading_widget.update("▪▪▪ Generating response...")
1242
+
1243
+ self._loading_frame += 1
1244
+ # Small delay between frames
1245
+ await asyncio.sleep(0.3)
1246
+ except Exception as e:
1247
+ # If any error occurs, use a simple fallback and continue
1248
+ log.error(f"Animation frame error: {str(e)}")
1249
+ try:
1250
+ loading_widget.update("▪▪▪ Generating response...")
1251
+ except:
1252
+ pass
1253
+ await asyncio.sleep(0.3)
1254
+
1255
+ except asyncio.CancelledError:
1256
+ # Normal cancellation
1257
+ pass
1258
+ except Exception as e:
1259
+ # Log any errors but don't crash
1260
+ log.error(f"Error in loading animation: {str(e)}")
1261
+ # Reset to basic text
1262
+ try:
1263
+ loading_widget.update("▪▪▪ Generating response...")
1264
+ except:
1265
+ pass
867
1266
 
868
1267
  def action_settings(self) -> None: # Modify SimpleChatApp action_settings
869
1268
  """Action to open/close settings panel via key binding."""
@@ -892,6 +1291,10 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
892
1291
  if not self.current_conversation:
893
1292
  self.notify("No active conversation", severity="warning")
894
1293
  return
1294
+
1295
+ # Create and mount the title input modal
1296
+ modal = TitleInputModal(self.current_conversation.title)
1297
+ await self.mount(modal)
895
1298
 
896
1299
  # --- Define the Modal Class ---
897
1300
  class ConfirmDialog(Static):
@@ -968,10 +1371,6 @@ class TitleInputModal(Static):
968
1371
  """Focus the input when the modal appears."""
969
1372
  self.query_one("#title-input", Input).focus()
970
1373
 
971
- # --- Show the modal ---
972
- modal = TitleInputModal(self.current_conversation.title)
973
- await self.mount(modal) # Use await for mounting
974
-
975
1374
  async def run_modal(self, modal_type: str, *args, **kwargs) -> bool:
976
1375
  """Run a modal dialog and return the result."""
977
1376
  if modal_type == "confirm_dialog":
@@ -1043,4 +1442,4 @@ def main(initial_text: Optional[str] = typer.Argument(None, help="Initial text t
1043
1442
  app.run() # Keep main function
1044
1443
 
1045
1444
  if __name__ == "__main__": # Keep main function entry point
1046
- typer.run(main) # Keep main function entry point
1445
+ typer.run(main) # Keep main function entry point