chat-console 0.2.9__py3-none-any.whl → 0.2.99__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,36 @@ Simplified version of Chat CLI with AI functionality
5
5
  import os
6
6
  import asyncio
7
7
  import typer
8
+ import logging
9
+ import time
8
10
  from typing import List, Optional, Callable, Awaitable
9
11
  from datetime import datetime
10
12
 
13
+ # Create a dedicated logger that definitely writes to a file
14
+ log_dir = os.path.expanduser("~/.cache/chat-cli")
15
+ os.makedirs(log_dir, exist_ok=True)
16
+ debug_log_file = os.path.join(log_dir, "debug.log")
17
+
18
+ # Configure the logger
19
+ file_handler = logging.FileHandler(debug_log_file)
20
+ file_handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s'))
21
+
22
+ # Get the logger and add the handler
23
+ debug_logger = logging.getLogger("chat-cli-debug")
24
+ debug_logger.setLevel(logging.DEBUG)
25
+ debug_logger.addHandler(file_handler)
26
+
27
+ # Add a convenience function to log to this file
28
+ def debug_log(message):
29
+ debug_logger.info(message)
30
+
11
31
  from textual.app import App, ComposeResult
12
32
  from textual.containers import Container, Horizontal, Vertical, ScrollableContainer, Center
13
33
  from textual.reactive import reactive
14
34
  from textual.widgets import Button, Input, Label, Static, Header, Footer, ListView, ListItem
15
35
  from textual.binding import Binding
16
36
  from textual import work, log, on
37
+ from textual.worker import Worker, WorkerState # Import Worker class and WorkerState enum
17
38
  from textual.screen import Screen
18
39
  from openai import OpenAI
19
40
  from app.models import Message, Conversation
@@ -25,7 +46,7 @@ from app.ui.model_selector import ModelSelector, StyleSelector
25
46
  from app.ui.chat_list import ChatList
26
47
  from app.ui.model_browser import ModelBrowser
27
48
  from app.api.base import BaseModelClient
28
- from app.utils import generate_streaming_response, save_settings_to_config, generate_conversation_title # Import title function
49
+ from app.utils import generate_streaming_response, save_settings_to_config, generate_conversation_title, resolve_model_id # Import resolver
29
50
  # Import version here to avoid potential circular import issues at top level
30
51
  from app import __version__
31
52
 
@@ -141,6 +162,15 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
141
162
  TITLE = "Chat Console"
142
163
  SUB_TITLE = "AI Chat Interface" # Keep SimpleChatApp SUB_TITLE
143
164
  DARK = True # Keep SimpleChatApp DARK
165
+
166
+ # Add better terminal handling to fix UI glitches
167
+ SCREENS = {}
168
+
169
+ # Force full screen mode and prevent background terminal showing through
170
+ FULL_SCREEN = True
171
+
172
+ # Force capturing all mouse events for better stability
173
+ CAPTURE_MOUSE = True
144
174
 
145
175
  # Ensure the log directory exists in a standard cache location
146
176
  log_dir = os.path.expanduser("~/.cache/chat-cli")
@@ -211,11 +241,17 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
211
241
  color: $text;
212
242
  content-align: center middle;
213
243
  text-align: center;
244
+ text-style: bold;
214
245
  }
215
246
 
216
247
  #loading-indicator.hidden { # Keep SimpleChatApp CSS
217
248
  display: none;
218
249
  }
250
+
251
+ #loading-indicator.model-loading {
252
+ background: $warning;
253
+ color: $text;
254
+ }
219
255
 
220
256
  #input-area { # Keep SimpleChatApp CSS
221
257
  width: 100%; # Keep SimpleChatApp CSS
@@ -313,12 +349,16 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
313
349
  current_conversation = reactive(None) # Keep SimpleChatApp reactive var
314
350
  is_generating = reactive(False) # Keep SimpleChatApp reactive var
315
351
  current_generation_task: Optional[asyncio.Task] = None # Add task reference
352
+ _loading_frame = 0 # Track animation frame
353
+ _loading_animation_task: Optional[asyncio.Task] = None # Animation task
316
354
 
317
355
  def __init__(self, initial_text: Optional[str] = None): # Keep SimpleChatApp __init__
318
356
  super().__init__() # Keep SimpleChatApp __init__
319
357
  self.db = ChatDatabase() # Keep SimpleChatApp __init__
320
358
  self.messages = [] # Keep SimpleChatApp __init__
321
- self.selected_model = CONFIG["default_model"] # Keep SimpleChatApp __init__
359
+ # Resolve the default model ID on initialization
360
+ default_model_from_config = CONFIG["default_model"]
361
+ self.selected_model = resolve_model_id(default_model_from_config)
322
362
  self.selected_style = CONFIG["default_style"] # Keep SimpleChatApp __init__
323
363
  self.initial_text = initial_text # Keep SimpleChatApp __init__
324
364
  # Removed self.input_widget instance variable
@@ -347,7 +387,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
347
387
  pass
348
388
 
349
389
  # Loading indicator
350
- yield Static("Generating response...", id="loading-indicator", classes="hidden")
390
+ yield Static("▪▪▪ Generating response...", id="loading-indicator", classes="hidden", markup=False)
351
391
 
352
392
  # Input area
353
393
  with Container(id="input-area"):
@@ -394,7 +434,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
394
434
  # Check for available models # Keep SimpleChatApp on_mount
395
435
  from app.api.ollama import OllamaClient # Keep SimpleChatApp on_mount
396
436
  try: # Keep SimpleChatApp on_mount
397
- ollama = OllamaClient() # Keep SimpleChatApp on_mount
437
+ ollama = await OllamaClient.create() # Keep SimpleChatApp on_mount
398
438
  models = await ollama.get_available_models() # Keep SimpleChatApp on_mount
399
439
  if not models: # Keep SimpleChatApp on_mount
400
440
  api_issues.append("- No Ollama models found") # Keep SimpleChatApp on_mount
@@ -481,7 +521,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
481
521
  # Get the client for the current model first and cancel the connection
482
522
  try:
483
523
  model = self.selected_model
484
- client = BaseModelClient.get_client_for_model(model)
524
+ client = await BaseModelClient.get_client_for_model(model)
485
525
 
486
526
  # Call the client's cancel method if it's supported
487
527
  if hasattr(client, 'cancel_stream'):
@@ -511,6 +551,15 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
511
551
  # This happens if is_generating is True, but no active task found to cancel
512
552
  log("No active generation task found, but is_generating=True. Resetting state.")
513
553
  self.is_generating = False
554
+
555
+ # Make sure to cancel animation task too
556
+ if self._loading_animation_task and not self._loading_animation_task.done():
557
+ try:
558
+ self._loading_animation_task.cancel()
559
+ except Exception as e:
560
+ log.error(f"Error cancelling animation task: {str(e)}")
561
+ self._loading_animation_task = None
562
+
514
563
  loading = self.query_one("#loading-indicator")
515
564
  loading.add_class("hidden")
516
565
  else:
@@ -537,20 +586,27 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
537
586
  pass
538
587
 
539
588
  async def update_messages_ui(self) -> None: # Keep SimpleChatApp update_messages_ui
540
- """Update the messages UI.""" # Keep SimpleChatApp update_messages_ui docstring
589
+ """Update the messages UI with improved stability.""" # Keep SimpleChatApp update_messages_ui docstring
541
590
  # Clear existing messages # Keep SimpleChatApp update_messages_ui
542
591
  messages_container = self.query_one("#messages-container") # Keep SimpleChatApp update_messages_ui
543
592
  messages_container.remove_children() # Keep SimpleChatApp update_messages_ui
544
593
 
545
- # Add messages with a small delay between each # Keep SimpleChatApp update_messages_ui
546
- for message in self.messages: # Keep SimpleChatApp update_messages_ui
547
- display = MessageDisplay(message, highlight_code=CONFIG["highlight_code"]) # Keep SimpleChatApp update_messages_ui
548
- messages_container.mount(display) # Keep SimpleChatApp update_messages_ui
549
- messages_container.scroll_end(animate=False) # Keep SimpleChatApp update_messages_ui
550
- await asyncio.sleep(0.01) # Small delay to prevent UI freezing # Keep SimpleChatApp update_messages_ui
551
-
552
- # Final scroll to bottom # Keep SimpleChatApp update_messages_ui
594
+ # Temporarily disable automatic refresh while mounting messages
595
+ # This avoids excessive layout calculations and reduces flickering
596
+ with self.batch_update():
597
+ # Batch add all messages first without any refresh/layout
598
+ for message in self.messages: # Keep SimpleChatApp update_messages_ui
599
+ display = MessageDisplay(message, highlight_code=CONFIG["highlight_code"]) # Keep SimpleChatApp update_messages_ui
600
+ messages_container.mount(display) # Keep SimpleChatApp update_messages_ui
601
+
602
+ # A small delay after mounting all messages helps with layout stability
603
+ await asyncio.sleep(0.05)
604
+
605
+ # Scroll after all messages are added without animation
553
606
  messages_container.scroll_end(animate=False) # Keep SimpleChatApp update_messages_ui
607
+
608
+ # Minimal refresh without full layout recalculation
609
+ self.refresh(layout=False)
554
610
 
555
611
  async def on_input_submitted(self, event: Input.Submitted) -> None: # Keep SimpleChatApp on_input_submitted
556
612
  """Handle input submission (Enter key in the main input).""" # Keep SimpleChatApp on_input_submitted docstring
@@ -588,6 +644,7 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
588
644
  # If this is the first message and dynamic titles are enabled, generate one
589
645
  if is_first_message and self.current_conversation and CONFIG.get("generate_dynamic_titles", True):
590
646
  log("First message detected, generating title...")
647
+ debug_log("First message detected, attempting to generate conversation title")
591
648
  title_generation_in_progress = True # Use a local flag
592
649
  loading = self.query_one("#loading-indicator")
593
650
  loading.remove_class("hidden") # Show loading for title gen
@@ -595,13 +652,71 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
595
652
  try:
596
653
  # Get appropriate client
597
654
  model = self.selected_model
598
- client = BaseModelClient.get_client_for_model(model)
655
+ debug_log(f"Selected model for title generation: '{model}'")
656
+
657
+ # Check if model is valid
658
+ if not model:
659
+ debug_log("Model is empty, falling back to default")
660
+ # Fallback to a safe default model - preferring OpenAI if key exists
661
+ if OPENAI_API_KEY:
662
+ model = "gpt-3.5-turbo"
663
+ debug_log("Falling back to OpenAI gpt-3.5-turbo for title generation")
664
+ elif ANTHROPIC_API_KEY:
665
+ model = "claude-instant-1.2"
666
+ debug_log("Falling back to Anthropic claude-instant-1.2 for title generation")
667
+ else:
668
+ # Last resort - check for a common Ollama model
669
+ try:
670
+ from app.api.ollama import OllamaClient
671
+ ollama = await OllamaClient.create()
672
+ models = await ollama.get_available_models()
673
+ if models and len(models) > 0:
674
+ debug_log(f"Found {len(models)} Ollama models, using first one")
675
+ model = models[0].get("id", "llama3")
676
+ else:
677
+ model = "llama3" # Common default
678
+ debug_log(f"Falling back to Ollama model: {model}")
679
+ except Exception as ollama_err:
680
+ debug_log(f"Error getting Ollama models: {str(ollama_err)}")
681
+ model = "llama3" # Final fallback
682
+ debug_log("Final fallback to llama3")
683
+
684
+ debug_log(f"Getting client for model: {model}")
685
+ client = await BaseModelClient.get_client_for_model(model)
686
+
599
687
  if client is None:
600
- raise Exception(f"No client available for model: {model}")
688
+ debug_log(f"No client available for model: {model}, trying to initialize")
689
+ # Try to determine client type and initialize manually
690
+ client_type = BaseModelClient.get_client_type_for_model(model)
691
+ if client_type:
692
+ debug_log(f"Found client type {client_type.__name__} for {model}, initializing")
693
+ try:
694
+ client = await client_type.create()
695
+ debug_log("Client initialized successfully")
696
+ except Exception as init_err:
697
+ debug_log(f"Error initializing client: {str(init_err)}")
698
+
699
+ if client is None:
700
+ debug_log("Could not initialize client, falling back to safer model")
701
+ # Try a different model as last resort
702
+ if OPENAI_API_KEY:
703
+ from app.api.openai import OpenAIClient
704
+ client = await OpenAIClient.create()
705
+ model = "gpt-3.5-turbo"
706
+ debug_log("Falling back to OpenAI for title generation")
707
+ elif ANTHROPIC_API_KEY:
708
+ from app.api.anthropic import AnthropicClient
709
+ client = await AnthropicClient.create()
710
+ model = "claude-instant-1.2"
711
+ debug_log("Falling back to Anthropic for title generation")
712
+ else:
713
+ raise Exception("No valid API clients available for title generation")
601
714
 
602
715
  # Generate title
603
716
  log(f"Calling generate_conversation_title with model: {model}")
717
+ debug_log(f"Calling generate_conversation_title with model: {model}")
604
718
  title = await generate_conversation_title(content, model, client)
719
+ debug_log(f"Generated title: {title}")
605
720
  log(f"Generated title: {title}")
606
721
 
607
722
  # Update conversation title in database
@@ -616,10 +731,17 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
616
731
 
617
732
  # Update conversation object
618
733
  self.current_conversation.title = title
734
+
735
+ # IMPORTANT: Save the successful model for consistency
736
+ # If the title was generated with a different model than initially selected,
737
+ # update the selected_model to match so the response uses the same model
738
+ debug_log(f"Using same model for chat response: '{model}'")
739
+ self.selected_model = model
619
740
 
620
741
  self.notify(f"Conversation title set to: {title}", severity="information", timeout=3)
621
742
 
622
743
  except Exception as e:
744
+ debug_log(f"Failed to generate title: {str(e)}")
623
745
  log.error(f"Failed to generate title: {str(e)}")
624
746
  self.notify(f"Failed to generate title: {str(e)}", severity="warning")
625
747
  finally:
@@ -628,7 +750,13 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
628
750
  # This check might be redundant if generate_response always shows it anyway
629
751
  if not self.is_generating:
630
752
  loading.add_class("hidden")
631
-
753
+
754
+ # Small delay to ensure state is updated
755
+ await asyncio.sleep(0.1)
756
+
757
+ # Log just before generate_response call
758
+ debug_log(f"About to call generate_response with model: '{self.selected_model}'")
759
+
632
760
  # Generate AI response (will set self.is_generating and handle loading indicator)
633
761
  await self.generate_response()
634
762
 
@@ -637,39 +765,148 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
637
765
 
638
766
  async def generate_response(self) -> None:
639
767
  """Generate an AI response using a non-blocking worker."""
768
+ # Import debug_log function from main
769
+ debug_log(f"Entering generate_response method")
770
+
640
771
  if not self.current_conversation or not self.messages:
772
+ debug_log("No current conversation or messages, returning")
641
773
  return
642
774
 
643
775
  self.is_generating = True
644
776
  log("Setting is_generating to True")
777
+ debug_log("Setting is_generating to True")
645
778
  loading = self.query_one("#loading-indicator")
646
779
  loading.remove_class("hidden")
780
+
781
+ # For Ollama models, show the loading indicator immediately
782
+ from app.api.ollama import OllamaClient
783
+ debug_log(f"Current selected model: '{self.selected_model}'")
784
+ client_type = BaseModelClient.get_client_type_for_model(self.selected_model)
785
+ debug_log(f"Client type: {client_type.__name__ if client_type else 'None'}")
786
+
787
+ if self.selected_model and client_type == OllamaClient:
788
+ log("Ollama model detected, showing immediate loading indicator")
789
+ debug_log("Ollama model detected, showing immediate loading indicator")
790
+ loading.add_class("model-loading")
791
+ # Update the loading indicator text directly
792
+ loading.update("⚙️ Preparing Ollama model...")
793
+ else:
794
+ loading.remove_class("model-loading")
795
+ # Start with a simple animation pattern that won't cause markup issues
796
+ self._loading_frame = 0
797
+ # Stop any existing animation task
798
+ if self._loading_animation_task and not self._loading_animation_task.done():
799
+ self._loading_animation_task.cancel()
800
+ # Start the animation
801
+ self._loading_animation_task = asyncio.create_task(self._animate_loading_task(loading))
647
802
 
648
803
  try:
649
804
  # Get conversation parameters
650
- model = self.selected_model
805
+ # Ensure the model ID is resolved before passing to the API client
806
+ unresolved_model = self.selected_model
807
+ model = resolve_model_id(unresolved_model)
808
+ log(f"Using model for generation: {model} (Resolved from: {unresolved_model})")
651
809
  style = self.selected_style
652
-
653
- # Convert messages to API format
810
+
811
+ debug_log(f"Using model: '{model}', style: '{style}'")
812
+
813
+ # Ensure we have a valid model
814
+ if not model:
815
+ debug_log("Model is empty, selecting a default model")
816
+ # Same fallback logic as in autotitling - this ensures consistency
817
+ if OPENAI_API_KEY:
818
+ model = "gpt-3.5-turbo"
819
+ debug_log("Falling back to OpenAI gpt-3.5-turbo")
820
+ elif ANTHROPIC_API_KEY:
821
+ model = "claude-instant-1.2"
822
+ debug_log("Falling back to Anthropic claude-instant-1.2")
823
+ else:
824
+ # Check for a common Ollama model
825
+ try:
826
+ ollama = await OllamaClient.create()
827
+ models = await ollama.get_available_models()
828
+ if models and len(models) > 0:
829
+ debug_log(f"Found {len(models)} Ollama models, using first one")
830
+ model = models[0].get("id", "llama3")
831
+ else:
832
+ model = "llama3" # Common default
833
+ debug_log(f"Falling back to Ollama model: {model}")
834
+ except Exception as ollama_err:
835
+ debug_log(f"Error getting Ollama models: {str(ollama_err)}")
836
+ model = "llama3" # Final fallback
837
+ debug_log("Final fallback to llama3")
838
+
839
+ # Convert messages to API format with enhanced error checking
654
840
  api_messages = []
655
- for msg in self.messages:
656
- api_messages.append({
657
- "role": msg.role,
658
- "content": msg.content
659
- })
841
+ debug_log(f"Converting {len(self.messages)} messages to API format")
842
+
843
+ for i, msg in enumerate(self.messages):
844
+ try:
845
+ debug_log(f"Processing message {i}: type={type(msg).__name__}, dir={dir(msg)}")
846
+ debug_log(f"Adding message to API format: role={msg.role}, content_len={len(msg.content)}")
847
+
848
+ # Create a fully validated message dict
849
+ message_dict = {
850
+ "role": msg.role if hasattr(msg, 'role') and msg.role else "user",
851
+ "content": msg.content if hasattr(msg, 'content') and msg.content else ""
852
+ }
853
+
854
+ api_messages.append(message_dict)
855
+ debug_log(f"Successfully added message {i}")
856
+ except Exception as e:
857
+ debug_log(f"Error adding message {i} to API format: {str(e)}")
858
+ # Create a safe fallback message
859
+ fallback_msg = {
860
+ "role": "user",
861
+ "content": str(msg) if msg is not None else "Error retrieving message content"
862
+ }
863
+ api_messages.append(fallback_msg)
864
+ debug_log(f"Added fallback message for {i}")
865
+
866
+ debug_log(f"Prepared {len(api_messages)} messages for API")
660
867
 
661
868
  # Get appropriate client
869
+ debug_log(f"Getting client for model: {model}")
662
870
  try:
663
- client = BaseModelClient.get_client_for_model(model)
871
+ client = await BaseModelClient.get_client_for_model(model)
872
+ debug_log(f"Client: {client.__class__.__name__ if client else 'None'}")
873
+
664
874
  if client is None:
665
- raise Exception(f"No client available for model: {model}")
875
+ debug_log(f"No client available for model: {model}, trying to initialize")
876
+ # Try to determine client type and initialize manually
877
+ client_type = BaseModelClient.get_client_type_for_model(model)
878
+ if client_type:
879
+ debug_log(f"Found client type {client_type.__name__} for {model}, initializing")
880
+ try:
881
+ client = await client_type.create()
882
+ debug_log(f"Successfully initialized {client_type.__name__}")
883
+ except Exception as init_err:
884
+ debug_log(f"Error initializing client: {str(init_err)}")
885
+
886
+ if client is None:
887
+ debug_log("Could not initialize client, falling back to safer model")
888
+ # Try a different model as last resort
889
+ if OPENAI_API_KEY:
890
+ from app.api.openai import OpenAIClient
891
+ client = await OpenAIClient.create()
892
+ model = "gpt-3.5-turbo"
893
+ debug_log("Falling back to OpenAI client")
894
+ elif ANTHROPIC_API_KEY:
895
+ from app.api.anthropic import AnthropicClient
896
+ client = await AnthropicClient.create()
897
+ model = "claude-instant-1.2"
898
+ debug_log("Falling back to Anthropic client")
899
+ else:
900
+ raise Exception("No valid API clients available")
666
901
  except Exception as e:
902
+ debug_log(f"Failed to initialize model client: {str(e)}")
667
903
  self.notify(f"Failed to initialize model client: {str(e)}", severity="error")
668
904
  self.is_generating = False
669
905
  loading.add_class("hidden")
670
906
  return
671
907
 
672
908
  # Start streaming response
909
+ debug_log("Creating assistant message with 'Thinking...'")
673
910
  assistant_message = Message(role="assistant", content="Thinking...")
674
911
  self.messages.append(assistant_message)
675
912
  messages_container = self.query_one("#messages-container")
@@ -682,96 +919,206 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
682
919
 
683
920
  # Stream chunks to the UI with synchronization
684
921
  update_lock = asyncio.Lock()
922
+ last_refresh_time = time.time() # Initialize refresh throttling timer
685
923
 
686
924
  async def update_ui(content: str):
925
+ # This function remains the same, called by the worker
687
926
  if not self.is_generating:
688
- log("update_ui called but is_generating is False, returning.")
927
+ debug_log("update_ui called but is_generating is False, returning.")
689
928
  return
690
929
 
930
+ # Make last_refresh_time accessible in inner scope
931
+ nonlocal last_refresh_time
932
+
691
933
  async with update_lock:
692
934
  try:
693
935
  # Clear thinking indicator on first content
694
936
  if assistant_message.content == "Thinking...":
937
+ debug_log("First content received, clearing 'Thinking...'")
695
938
  assistant_message.content = ""
696
939
 
697
- # Update message with full content so far
940
+ # Update the message object with the full content
698
941
  assistant_message.content = content
699
- # Update UI with full content
942
+
943
+ # Update UI with the content - this no longer triggers refresh itself
700
944
  await message_display.update_content(content)
701
- # Force a refresh and scroll
702
- self.refresh(layout=True)
703
- await asyncio.sleep(0.05) # Longer delay for UI stability
704
- messages_container.scroll_end(animate=False)
705
- # Force another refresh to ensure content is visible
706
- self.refresh(layout=True)
945
+
946
+ # Much more aggressive throttling of UI updates to eliminate visual jitter
947
+ # By using a larger modulo value, we significantly reduce refresh frequency
948
+ # This improves stability at the cost of slightly choppier animations
949
+ content_length = len(content)
950
+
951
+ # Define some key refresh points
952
+ new_paragraph = content.endswith("\n") and content.count("\n") > 0
953
+ do_refresh = (
954
+ content_length < 5 or # Only first few tokens
955
+ content_length % 64 == 0 or # Very infrequent periodic updates
956
+ new_paragraph # Refresh on paragraph breaks
957
+ )
958
+
959
+ # Check if it's been enough time since last refresh (250ms minimum)
960
+ current_time = time.time()
961
+ time_since_refresh = current_time - last_refresh_time
962
+
963
+ if do_refresh and time_since_refresh > 0.25:
964
+ # Store the time we did the refresh
965
+ last_refresh_time = current_time
966
+ # Skip layout updates completely during streaming
967
+ # Just ensure content is still visible by scrolling
968
+ messages_container.scroll_end(animate=False)
707
969
  except Exception as e:
970
+ debug_log(f"Error updating UI: {str(e)}")
708
971
  log.error(f"Error updating UI: {str(e)}")
709
972
 
710
- # Define worker for background processing
711
- @work(exit_on_error=True)
712
- async def run_generation_worker():
713
- try:
714
- # Generate the response in background
715
- full_response = await generate_streaming_response(
716
- self,
717
- api_messages,
718
- model,
719
- style,
720
- client,
721
- update_ui
722
- )
723
-
724
- # Save complete response to database
725
- if self.is_generating and full_response:
726
- log("Generation completed normally, saving to database")
727
- self.db.add_message(
728
- self.current_conversation.id,
729
- "assistant",
730
- full_response
731
- )
732
-
733
- # Final UI refresh
734
- self.refresh(layout=True)
735
-
736
- except asyncio.CancelledError:
737
- log.warning("Generation worker was cancelled")
738
- # Remove the incomplete message
739
- if self.messages and self.messages[-1].role == "assistant":
740
- self.messages.pop()
741
- await self.update_messages_ui()
742
- self.notify("Generation stopped by user", severity="warning", timeout=2)
743
-
744
- except Exception as e:
745
- log.error(f"Error in generation worker: {str(e)}")
746
- self.notify(f"Generation error: {str(e)}", severity="error", timeout=5)
747
- # Add error message to UI
748
- if self.messages and self.messages[-1].role == "assistant":
749
- self.messages.pop() # Remove thinking message
750
- error_msg = f"Error: {str(e)}"
751
- self.messages.append(Message(role="assistant", content=error_msg))
752
- await self.update_messages_ui()
753
-
754
- finally:
755
- # Always clean up state and UI
756
- log("Generation worker completed, resetting state")
757
- self.is_generating = False
758
- self.current_generation_task = None
759
- loading = self.query_one("#loading-indicator")
760
- loading.add_class("hidden")
761
- self.refresh(layout=True)
762
- self.query_one("#message-input").focus()
763
-
764
- # Start the worker and keep a reference to it
765
- worker = run_generation_worker()
973
+ # --- Remove the inner run_generation_worker function ---
974
+
975
+ # Start the worker directly using the imported function
976
+ debug_log("Starting generate_streaming_response worker")
977
+ # Call the @work decorated function directly
978
+ worker = generate_streaming_response(
979
+ self,
980
+ api_messages,
981
+ model,
982
+ style,
983
+ client,
984
+ update_ui # Pass the callback function
985
+ )
766
986
  self.current_generation_task = worker
767
-
987
+ # Worker completion will be handled by on_worker_state_changed
988
+
768
989
  except Exception as e:
769
- log.error(f"Error setting up generation: {str(e)}")
990
+ # This catches errors during the *setup* before the worker starts
991
+ debug_log(f"Error setting up generation worker: {str(e)}")
992
+ log.error(f"Error setting up generation worker: {str(e)}")
770
993
  self.notify(f"Error: {str(e)}", severity="error")
994
+ # Ensure cleanup if setup fails
995
+ self.is_generating = False # Reset state
996
+ self.current_generation_task = None
997
+ if self._loading_animation_task and not self._loading_animation_task.done():
998
+ self._loading_animation_task.cancel()
999
+ self._loading_animation_task = None
1000
+ try:
1001
+ loading = self.query_one("#loading-indicator")
1002
+ loading.add_class("hidden")
1003
+ self.query_one("#message-input").focus()
1004
+ except Exception:
1005
+ pass # Ignore UI errors during cleanup
1006
+
1007
+ # Rename this method slightly to avoid potential conflicts and clarify purpose
1008
+ async def _handle_generation_result(self, worker: Worker[Optional[str]]) -> None:
1009
+ """Handles the result of the generation worker (success, error, cancelled)."""
1010
+ # Import debug_log again for safety within this callback context
1011
+ try:
1012
+ from app.main import debug_log
1013
+ except ImportError:
1014
+ debug_log = lambda msg: None
1015
+
1016
+ debug_log(f"Generation worker completed. State: {worker.state}")
1017
+
1018
+ try:
1019
+ if worker.state == "cancelled":
1020
+ debug_log("Generation worker was cancelled")
1021
+ log.warning("Generation worker was cancelled")
1022
+ # Remove the incomplete message
1023
+ if self.messages and self.messages[-1].role == "assistant":
1024
+ debug_log("Removing incomplete assistant message")
1025
+ self.messages.pop()
1026
+ await self.update_messages_ui()
1027
+ self.notify("Generation stopped by user", severity="warning", timeout=2)
1028
+
1029
+ elif worker.state == "error":
1030
+ error = worker.error
1031
+ debug_log(f"Error in generation worker: {error}")
1032
+ log.error(f"Error in generation worker: {error}")
1033
+ self.notify(f"Generation error: {error}", severity="error", timeout=5)
1034
+ # Add error message to UI
1035
+ if self.messages and self.messages[-1].role == "assistant":
1036
+ debug_log("Removing thinking message")
1037
+ self.messages.pop() # Remove thinking message
1038
+ error_msg = f"Error: {error}"
1039
+ debug_log(f"Adding error message: {error_msg}")
1040
+ self.messages.append(Message(role="assistant", content=error_msg))
1041
+ await self.update_messages_ui()
1042
+
1043
+ elif worker.state == "success":
1044
+ full_response = worker.result
1045
+ debug_log("Generation completed normally, saving to database")
1046
+ log("Generation completed normally, saving to database")
1047
+ # Save complete response to database (check if response is valid)
1048
+ if full_response and isinstance(full_response, str):
1049
+ self.db.add_message(
1050
+ self.current_conversation.id,
1051
+ "assistant",
1052
+ full_response
1053
+ )
1054
+ # Update the final message object content (optional, UI should be up-to-date)
1055
+ if self.messages and self.messages[-1].role == "assistant":
1056
+ self.messages[-1].content = full_response
1057
+ else:
1058
+ debug_log("Worker finished successfully but response was empty or invalid.")
1059
+ # Handle case where 'Thinking...' might still be the last message
1060
+ if self.messages and self.messages[-1].role == "assistant" and self.messages[-1].content == "Thinking...":
1061
+ self.messages.pop() # Remove 'Thinking...' if no content arrived
1062
+ await self.update_messages_ui()
1063
+
1064
+ # Final UI refresh with minimal layout recalculation
1065
+ # Use layout=False to prevent UI jumping at the end
1066
+ self.refresh(layout=False)
1067
+ await asyncio.sleep(0.1) # Allow UI to stabilize
1068
+ messages_container = self.query_one("#messages-container")
1069
+ messages_container.scroll_end(animate=False)
1070
+
1071
+ except Exception as e:
1072
+ # Catch any unexpected errors during the callback itself
1073
+ debug_log(f"Error in on_generation_complete callback: {str(e)}")
1074
+ log.error(f"Error in on_generation_complete callback: {str(e)}")
1075
+ self.notify(f"Internal error handling response: {str(e)}", severity="error")
1076
+
1077
+ finally:
1078
+ # Always clean up state and UI, regardless of worker outcome
1079
+ debug_log("Cleaning up after generation worker")
771
1080
  self.is_generating = False
772
- loading = self.query_one("#loading-indicator")
773
- loading.add_class("hidden")
774
- self.query_one("#message-input").focus()
1081
+ self.current_generation_task = None
1082
+
1083
+ # Stop the animation task
1084
+ if self._loading_animation_task and not self._loading_animation_task.done():
1085
+ debug_log("Cancelling loading animation task")
1086
+ self._loading_animation_task.cancel()
1087
+ self._loading_animation_task = None
1088
+
1089
+ try:
1090
+ loading = self.query_one("#loading-indicator")
1091
+ loading.add_class("hidden")
1092
+ self.refresh(layout=True) # Refresh after hiding loading
1093
+ self.query_one("#message-input").focus()
1094
+ except Exception as ui_err:
1095
+ debug_log(f"Error during final UI cleanup: {str(ui_err)}")
1096
+ log.error(f"Error during final UI cleanup: {str(ui_err)}")
1097
+
1098
+ @on(Worker.StateChanged)
1099
+ async def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
1100
+ """Handle worker state changes."""
1101
+ # Import debug_log again for safety within this callback context
1102
+ try:
1103
+ from app.main import debug_log
1104
+ except ImportError:
1105
+ debug_log = lambda msg: None
1106
+
1107
+ debug_log(f"Worker {event.worker.name} state changed to {event.state}")
1108
+
1109
+ # Check if this is the generation worker we are tracking
1110
+ if event.worker is self.current_generation_task:
1111
+ # Check if the worker has reached a final state by comparing against enum values
1112
+ final_states = {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}
1113
+ if event.state in final_states:
1114
+ debug_log(f"Generation worker ({event.worker.name}) reached final state: {event.state}")
1115
+ # Call the handler function
1116
+ await self._handle_generation_result(event.worker)
1117
+ else:
1118
+ debug_log(f"Generation worker ({event.worker.name}) is in intermediate state: {event.state}")
1119
+ else:
1120
+ debug_log(f"State change event from unrelated worker: {event.worker.name}")
1121
+
775
1122
 
776
1123
  def on_model_selector_model_selected(self, event: ModelSelector.ModelSelected) -> None: # Keep SimpleChatApp on_model_selector_model_selected
777
1124
  """Handle model selection""" # Keep SimpleChatApp on_model_selector_model_selected docstring
@@ -862,8 +1209,23 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
862
1209
  await self.update_messages_ui() # Keep SimpleChatApp view_chat_history
863
1210
 
864
1211
  # Update model and style selectors # Keep SimpleChatApp view_chat_history
865
- self.selected_model = self.current_conversation.model # Keep SimpleChatApp view_chat_history
1212
+ # Resolve the model ID loaded from the conversation data
1213
+ loaded_model_id = self.current_conversation.model
1214
+ resolved_model_id = resolve_model_id(loaded_model_id)
1215
+ log(f"Loaded model ID from history: {loaded_model_id}, Resolved to: {resolved_model_id}")
1216
+
1217
+ self.selected_model = resolved_model_id # Use the resolved ID
866
1218
  self.selected_style = self.current_conversation.style # Keep SimpleChatApp view_chat_history
1219
+
1220
+ # Update settings panel selectors if they exist
1221
+ try:
1222
+ model_selector = self.query_one(ModelSelector)
1223
+ model_selector.set_selected_model(self.selected_model) # Use resolved ID here too
1224
+ style_selector = self.query_one(StyleSelector)
1225
+ style_selector.set_selected_style(self.selected_style)
1226
+ except Exception as e:
1227
+ log(f"Error updating selectors after history load: {e}")
1228
+
867
1229
  self.update_app_info() # Update info bar after loading history
868
1230
 
869
1231
  self.push_screen(HistoryScreen(conversations, handle_selection)) # Keep SimpleChatApp view_chat_history
@@ -879,6 +1241,53 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
879
1241
  """Open the Ollama model browser screen."""
880
1242
  # Always trigger regardless of focus
881
1243
  self.push_screen(ModelBrowserScreen())
1244
+
1245
+ async def _animate_loading_task(self, loading_widget: Static) -> None:
1246
+ """Animate the loading indicator with a simple text animation"""
1247
+ try:
1248
+ # Animation frames (simple text animation)
1249
+ frames = [
1250
+ "▪▫▫ Generating response...",
1251
+ "▪▪▫ Generating response...",
1252
+ "▪▪▪ Generating response...",
1253
+ "▫▪▪ Generating response...",
1254
+ "▫▫▪ Generating response...",
1255
+ "▫▫▫ Generating response..."
1256
+ ]
1257
+
1258
+ while self.is_generating:
1259
+ try:
1260
+ # Update the loading text with safety checks
1261
+ if frames and len(frames) > 0:
1262
+ frame_idx = self._loading_frame % len(frames)
1263
+ loading_widget.update(frames[frame_idx])
1264
+ else:
1265
+ # Fallback if frames is empty
1266
+ loading_widget.update("▪▪▪ Generating response...")
1267
+
1268
+ self._loading_frame += 1
1269
+ # Small delay between frames
1270
+ await asyncio.sleep(0.3)
1271
+ except Exception as e:
1272
+ # If any error occurs, use a simple fallback and continue
1273
+ log.error(f"Animation frame error: {str(e)}")
1274
+ try:
1275
+ loading_widget.update("▪▪▪ Generating response...")
1276
+ except:
1277
+ pass
1278
+ await asyncio.sleep(0.3)
1279
+
1280
+ except asyncio.CancelledError:
1281
+ # Normal cancellation
1282
+ pass
1283
+ except Exception as e:
1284
+ # Log any errors but don't crash
1285
+ log.error(f"Error in loading animation: {str(e)}")
1286
+ # Reset to basic text
1287
+ try:
1288
+ loading_widget.update("▪▪▪ Generating response...")
1289
+ except:
1290
+ pass
882
1291
 
883
1292
  def action_settings(self) -> None: # Modify SimpleChatApp action_settings
884
1293
  """Action to open/close settings panel via key binding."""
@@ -907,6 +1316,10 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
907
1316
  if not self.current_conversation:
908
1317
  self.notify("No active conversation", severity="warning")
909
1318
  return
1319
+
1320
+ # Create and mount the title input modal
1321
+ modal = TitleInputModal(self.current_conversation.title)
1322
+ await self.mount(modal)
910
1323
 
911
1324
  # --- Define the Modal Class ---
912
1325
  class ConfirmDialog(Static):
@@ -983,10 +1396,6 @@ class TitleInputModal(Static):
983
1396
  """Focus the input when the modal appears."""
984
1397
  self.query_one("#title-input", Input).focus()
985
1398
 
986
- # --- Show the modal ---
987
- modal = TitleInputModal(self.current_conversation.title)
988
- await self.mount(modal) # Use await for mounting
989
-
990
1399
  async def run_modal(self, modal_type: str, *args, **kwargs) -> bool:
991
1400
  """Run a modal dialog and return the result."""
992
1401
  if modal_type == "confirm_dialog":
@@ -1058,4 +1467,4 @@ def main(initial_text: Optional[str] = typer.Argument(None, help="Initial text t
1058
1467
  app.run() # Keep main function
1059
1468
 
1060
1469
  if __name__ == "__main__": # Keep main function entry point
1061
- typer.run(main) # Keep main function entry point
1470
+ typer.run(main) # Keep main function entry point