chat-console 0.2.0__py3-none-any.whl → 0.2.5__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
@@ -23,6 +23,7 @@ from app.config import CONFIG, OPENAI_API_KEY, ANTHROPIC_API_KEY, OLLAMA_BASE_UR
23
23
  from app.ui.chat_interface import MessageDisplay, InputWithFocus
24
24
  from app.ui.model_selector import ModelSelector, StyleSelector
25
25
  from app.ui.chat_list import ChatList
26
+ from app.ui.model_browser import ModelBrowser
26
27
  from app.api.base import BaseModelClient
27
28
  from app.utils import generate_streaming_response, save_settings_to_config, generate_conversation_title # Import title function
28
29
  # Import version here to avoid potential circular import issues at top level
@@ -30,6 +31,26 @@ from app import __version__
30
31
 
31
32
  # --- Remove SettingsScreen class entirely ---
32
33
 
34
+ class ModelBrowserScreen(Screen):
35
+ """Screen for browsing Ollama models."""
36
+
37
+ BINDINGS = [
38
+ Binding("escape", "pop_screen", "Close"),
39
+ ]
40
+
41
+ CSS = """
42
+ #browser-wrapper {
43
+ width: 100%;
44
+ height: 100%;
45
+ background: $surface;
46
+ }
47
+ """
48
+
49
+ def compose(self) -> ComposeResult:
50
+ """Create the model browser screen layout."""
51
+ with Container(id="browser-wrapper"):
52
+ yield ModelBrowser()
53
+
33
54
  class HistoryScreen(Screen):
34
55
  """Screen for viewing chat history."""
35
56
 
@@ -279,18 +300,19 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
279
300
 
280
301
  BINDINGS = [ # Keep SimpleChatApp BINDINGS, ensure Enter is not globally bound for settings
281
302
  Binding("q", "quit", "Quit", show=True, key_display="q"),
282
- # Add priority=True to ensure these capture before input
283
- Binding("n", "action_new_conversation", "New Chat", show=True, key_display="n", priority=True),
284
- Binding("c", "action_new_conversation", "New Chat", show=False, key_display="c", priority=True), # Add priority to alias too
303
+ # Removed binding for "n" (new chat) since there's a dedicated button
304
+ Binding("c", "action_new_conversation", "New Chat", show=False, key_display="c", priority=True), # Keep alias with priority
285
305
  Binding("escape", "escape", "Cancel / Stop", show=True, key_display="esc"), # Escape might close settings panel too
286
306
  Binding("ctrl+c", "quit", "Quit", show=False),
287
307
  Binding("h", "view_history", "History", show=True, key_display="h", priority=True), # Add priority
288
308
  Binding("s", "settings", "Settings", show=True, key_display="s", priority=True), # Add priority
289
- Binding("t", "action_update_title", "Update Title", show=True, key_display="t", priority=True), # Add priority
309
+ # Removed binding for "t" (title update) since there's a dedicated button
310
+ Binding("m", "model_browser", "Model Browser", show=True, key_display="m", priority=True), # Add model browser binding
290
311
  ] # Keep SimpleChatApp BINDINGS end
291
312
 
292
313
  current_conversation = reactive(None) # Keep SimpleChatApp reactive var
293
314
  is_generating = reactive(False) # Keep SimpleChatApp reactive var
315
+ current_generation_task: Optional[asyncio.Task] = None # Add task reference
294
316
 
295
317
  def __init__(self, initial_text: Optional[str] = None): # Keep SimpleChatApp __init__
296
318
  super().__init__() # Keep SimpleChatApp __init__
@@ -441,26 +463,32 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
441
463
  await self.create_new_conversation() # Keep SimpleChatApp action_new_conversation
442
464
  log("action_new_conversation finished") # Added log
443
465
 
444
- def action_escape(self) -> None: # Modify SimpleChatApp action_escape
466
+ def action_escape(self) -> None:
445
467
  """Handle escape key globally."""
446
- log("action_escape triggered") # Added log
468
+ log("action_escape triggered")
447
469
  settings_panel = self.query_one("#settings-panel")
448
- log(f"Settings panel visible: {settings_panel.has_class('visible')}") # Added log
470
+ log(f"Settings panel visible: {settings_panel.has_class('visible')}")
471
+
449
472
  if settings_panel.has_class("visible"):
450
- log("Hiding settings panel") # Added log
451
- # If settings panel is visible, hide it
473
+ log("Hiding settings panel")
452
474
  settings_panel.remove_class("visible")
453
- self.query_one("#message-input").focus() # Focus input after closing settings
475
+ self.query_one("#message-input").focus()
454
476
  elif self.is_generating:
455
- log("Stopping generation") # Added log
456
- # Otherwise, stop generation if running
457
- self.is_generating = False # Keep SimpleChatApp action_escape
458
- self.notify("Generation stopped", severity="warning") # Keep SimpleChatApp action_escape
459
- loading = self.query_one("#loading-indicator") # Keep SimpleChatApp action_escape
460
- loading.add_class("hidden") # Keep SimpleChatApp action_escape
461
- else: # Optional: Add other escape behavior for the main screen if desired # Keep SimpleChatApp action_escape comment
462
- log("Escape pressed, but settings not visible and not generating.") # Added log
463
- # pass # Keep SimpleChatApp action_escape comment
477
+ log("Attempting to cancel generation task")
478
+ if self.current_generation_task and not self.current_generation_task.done():
479
+ log("Cancelling active generation task.")
480
+ self.current_generation_task.cancel()
481
+ # The finally block in generate_response will handle is_generating = False and UI updates
482
+ self.notify("Stopping generation...", severity="warning", timeout=2) # Notify user immediately
483
+ else:
484
+ # This case might happen if is_generating is True, but no active task found to cancel. Resetting flag.")
485
+ self.is_generating = False # Reset flag manually if task is missing
486
+ loading = self.query_one("#loading-indicator")
487
+ loading.add_class("hidden")
488
+ else:
489
+ log("Escape pressed, but settings not visible and not actively generating.")
490
+ # Optionally add other escape behaviors here if needed for the main screen
491
+ # e.g., clear input, deselect item, etc.
464
492
 
465
493
  def update_app_info(self) -> None:
466
494
  """Update the displayed app information."""
@@ -651,10 +679,10 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
651
679
  log.error(f"Error updating UI: {str(e)}") # Use log instead of logger
652
680
 
653
681
  # Generate the response with timeout and cleanup # Keep SimpleChatApp generate_response
654
- generation_task = None # Keep SimpleChatApp generate_response
682
+ self.current_generation_task = None # Clear previous task reference
655
683
  try: # Keep SimpleChatApp generate_response
656
684
  # Create a task for the response generation # Keep SimpleChatApp generate_response
657
- generation_task = asyncio.create_task( # Keep SimpleChatApp generate_response
685
+ self.current_generation_task = asyncio.create_task( # Keep SimpleChatApp generate_response
658
686
  generate_streaming_response( # Keep SimpleChatApp generate_response
659
687
  self, # Pass the app instance
660
688
  api_messages, # Keep SimpleChatApp generate_response
@@ -666,11 +694,13 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
666
694
  ) # Keep SimpleChatApp generate_response
667
695
 
668
696
  # Wait for response with timeout # Keep SimpleChatApp generate_response
669
- full_response = await asyncio.wait_for(generation_task, timeout=60) # Longer timeout # Keep SimpleChatApp generate_response
697
+ log.info(f"Waiting for generation task {self.current_generation_task} with timeout...") # Add log
698
+ full_response = await asyncio.wait_for(self.current_generation_task, timeout=60) # Longer timeout # Keep SimpleChatApp generate_response
699
+ log.info(f"Generation task {self.current_generation_task} completed. Full response length: {len(full_response) if full_response else 0}") # Add log
670
700
 
671
- # Save to database only if we got a complete response # Keep SimpleChatApp generate_response
672
- if self.is_generating and full_response: # Keep SimpleChatApp generate_response
673
- log("Generation finished, saving full response to DB") # Added log
701
+ # Save to database only if we got a complete response and weren't cancelled
702
+ if self.is_generating and full_response: # Check is_generating flag here
703
+ log("Generation finished normally, saving full response to DB") # Added log
674
704
  self.db.add_message( # Keep SimpleChatApp generate_response
675
705
  self.current_conversation.id, # Keep SimpleChatApp generate_response
676
706
  "assistant", # Keep SimpleChatApp generate_response
@@ -679,11 +709,24 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
679
709
  # Force a final refresh # Keep SimpleChatApp generate_response
680
710
  self.refresh(layout=True) # Keep SimpleChatApp generate_response
681
711
  await asyncio.sleep(0.1) # Wait for UI to update # Keep SimpleChatApp generate_response
682
- elif not full_response:
712
+ elif not full_response and self.is_generating: # Only log if not cancelled
683
713
  log("Generation finished but full_response is empty/None") # Added log
714
+ else:
715
+ # This case handles cancellation where full_response might be partial or None
716
+ log("Generation was cancelled or finished without a full response.")
717
+
718
+ except asyncio.CancelledError: # Handle cancellation explicitly
719
+ log.warning("Generation task was cancelled.")
720
+ self.notify("Generation stopped by user.", severity="warning")
721
+ # Remove the potentially incomplete message from UI state
722
+ if self.messages and self.messages[-1].role == "assistant":
723
+ self.messages.pop()
724
+ await self.update_messages_ui() # Update UI to remove partial message
684
725
 
685
726
  except asyncio.TimeoutError: # Keep SimpleChatApp generate_response
686
- log.error("Response generation timed out") # Use log instead of logger
727
+ log.error(f"Response generation timed out waiting for task {self.current_generation_task}") # Use log instead of logger
728
+ # Log state at timeout
729
+ log.error(f"Timeout state: is_generating={self.is_generating}, task_done={self.current_generation_task.done() if self.current_generation_task else 'N/A'}")
687
730
  error_msg = "Response generation timed out. The model may be busy or unresponsive. Please try again." # Keep SimpleChatApp generate_response
688
731
  self.notify(error_msg, severity="error") # Keep SimpleChatApp generate_response
689
732
 
@@ -695,31 +738,36 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
695
738
  await self.update_messages_ui() # Keep SimpleChatApp generate_response
696
739
 
697
740
  finally: # Keep SimpleChatApp generate_response
698
- # Ensure task is properly cancelled and cleaned up # Keep SimpleChatApp generate_response
699
- if generation_task: # Keep SimpleChatApp generate_response
700
- if not generation_task.done(): # Keep SimpleChatApp generate_response
701
- log("Cancelling generation task") # Added log
702
- generation_task.cancel() # Keep SimpleChatApp generate_response
703
- try: # Keep SimpleChatApp generate_response
704
- await generation_task # Keep SimpleChatApp generate_response
705
- except (asyncio.CancelledError, Exception) as e: # Keep SimpleChatApp generate_response
706
- log.error(f"Error cleaning up generation task: {str(e)}") # Use log instead of logger
707
-
741
+ # Ensure flag is reset and task reference is cleared
742
+ log(f"Setting is_generating to False in finally block") # Added log
743
+ self.is_generating = False # Keep SimpleChatApp generate_response
744
+ self.current_generation_task = None # Clear task reference
745
+ loading = self.query_one("#loading-indicator") # Keep SimpleChatApp generate_response
746
+ loading.add_class("hidden") # Keep SimpleChatApp generate_response
708
747
  # Force a final UI refresh # Keep SimpleChatApp generate_response
709
748
  self.refresh(layout=True) # Keep SimpleChatApp generate_response
710
749
 
711
750
  except Exception as e: # Keep SimpleChatApp generate_response
712
- log.error(f"Exception during generate_response: {str(e)}") # Added log
751
+ # Catch any other unexpected errors during generation setup/handling
752
+ log.error(f"Unexpected exception during generate_response: {str(e)}") # Added log
713
753
  self.notify(f"Error generating response: {str(e)}", severity="error") # Keep SimpleChatApp generate_response
714
- # Add error message # Keep SimpleChatApp generate_response
715
- error_msg = f"Error generating response: {str(e)}" # Keep SimpleChatApp generate_response
754
+ # Add error message to UI # Keep SimpleChatApp generate_response
755
+ error_msg = f"Error: {str(e)}" # Keep SimpleChatApp generate_response
716
756
  self.messages.append(Message(role="assistant", content=error_msg)) # Keep SimpleChatApp generate_response
717
757
  await self.update_messages_ui() # Keep SimpleChatApp generate_response
718
- finally: # Keep SimpleChatApp generate_response
719
- log(f"Setting is_generating to False in finally block") # Added log
720
- self.is_generating = False # Keep SimpleChatApp generate_response
758
+ # The finally block below will handle resetting is_generating and hiding loading
759
+
760
+ finally: # Keep SimpleChatApp generate_response - This finally block now primarily handles cleanup
761
+ log(f"Ensuring is_generating is False and task is cleared in outer finally block") # Added log
762
+ self.is_generating = False # Ensure flag is always reset
763
+ self.current_generation_task = None # Ensure task ref is cleared
721
764
  loading = self.query_one("#loading-indicator") # Keep SimpleChatApp generate_response
722
- loading.add_class("hidden") # Keep SimpleChatApp generate_response
765
+ loading.add_class("hidden") # Ensure loading indicator is hidden
766
+ # Re-focus input after generation attempt (success, failure, or cancel)
767
+ try:
768
+ self.query_one("#message-input").focus()
769
+ except Exception:
770
+ pass # Ignore if input not found
723
771
 
724
772
  def on_model_selector_model_selected(self, event: ModelSelector.ModelSelected) -> None: # Keep SimpleChatApp on_model_selector_model_selected
725
773
  """Handle model selection""" # Keep SimpleChatApp on_model_selector_model_selected docstring
@@ -822,6 +870,11 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
822
870
  input_widget = self.query_one("#message-input", Input) # Keep SimpleChatApp action_view_history
823
871
  if not input_widget.has_focus: # Keep SimpleChatApp action_view_history
824
872
  await self.view_chat_history() # Keep SimpleChatApp action_view_history
873
+
874
+ def action_model_browser(self) -> None:
875
+ """Open the Ollama model browser screen."""
876
+ # Always trigger regardless of focus
877
+ self.push_screen(ModelBrowserScreen())
825
878
 
826
879
  def action_settings(self) -> None: # Modify SimpleChatApp action_settings
827
880
  """Action to open/close settings panel via key binding."""
@@ -852,40 +905,110 @@ class SimpleChatApp(App): # Keep SimpleChatApp class definition
852
905
  return
853
906
 
854
907
  # --- Define the Modal Class ---
855
- class TitleInputModal(Static):
856
- def __init__(self, current_title: str):
908
+ class ConfirmDialog(Static):
909
+ """A simple confirmation dialog."""
910
+
911
+ class Confirmed(Message):
912
+ """Message sent when the dialog is confirmed."""
913
+ def __init__(self, confirmed: bool):
914
+ self.confirmed = confirmed
915
+ super().__init__()
916
+
917
+ def __init__(self, message: str):
857
918
  super().__init__()
858
- self.current_title = current_title
859
-
919
+ self.message = message
920
+
860
921
  def compose(self) -> ComposeResult:
861
- with Vertical(id="title-modal"):
862
- yield Static("Enter new conversation title:", id="modal-label")
863
- yield Input(value=self.current_title, id="title-input")
922
+ with Vertical(id="confirm-dialog"):
923
+ yield Static(self.message, id="confirm-message")
864
924
  with Horizontal():
865
- yield Button("Cancel", id="cancel-button", variant="error")
866
- yield Button("Update", id="update-button", variant="success")
867
-
868
- @on(Button.Pressed, "#update-button")
869
- def update_title(self, event: Button.Pressed) -> None:
870
- input_widget = self.query_one("#title-input", Input)
871
- new_title = input_widget.value.strip()
872
- if new_title:
873
- # Call the app's update method asynchronously
874
- asyncio.create_task(self.app.update_conversation_title(new_title))
875
- self.remove() # Close the modal
876
-
877
- @on(Button.Pressed, "#cancel-button")
925
+ yield Button("No", id="no-button", variant="error")
926
+ yield Button("Yes", id="yes-button", variant="success")
927
+
928
+ @on(Button.Pressed, "#yes-button")
929
+ def confirm(self, event: Button.Pressed) -> None:
930
+ self.post_message(self.Confirmed(True))
931
+ self.remove() # Close the dialog
932
+
933
+ @on(Button.Pressed, "#no-button")
878
934
  def cancel(self, event: Button.Pressed) -> None:
879
- self.remove() # Close the modal
880
-
935
+ self.post_message(self.Confirmed(False))
936
+ self.remove() # Close the dialog
937
+
938
+ def on_confirmed(self, event: Confirmed) -> None:
939
+ """Event handler for confirmation - used by the app to get the result."""
940
+ pass
941
+
881
942
  def on_mount(self) -> None:
882
- """Focus the input when the modal appears."""
883
- self.query_one("#title-input", Input).focus()
943
+ """Set the CSS style when mounted."""
944
+ self.styles.width = "40"
945
+ self.styles.height = "auto"
946
+ self.styles.background = "var(--surface)"
947
+ self.styles.border = "thick var(--primary)"
948
+ self.styles.align = "center middle"
949
+ self.styles.padding = "1 2"
950
+ self.styles.layer = "modal"
951
+
952
+ class TitleInputModal(Static):
953
+ def __init__(self, current_title: str):
954
+ super().__init__()
955
+ self.current_title = current_title
956
+
957
+ def compose(self) -> ComposeResult:
958
+ with Vertical(id="title-modal"):
959
+ yield Static("Enter new conversation title:", id="modal-label")
960
+ yield Input(value=self.current_title, id="title-input")
961
+ with Horizontal():
962
+ yield Button("Cancel", id="cancel-button", variant="error")
963
+ yield Button("Update", id="update-button", variant="success")
964
+
965
+ @on(Button.Pressed, "#update-button")
966
+ def update_title(self, event: Button.Pressed) -> None:
967
+ input_widget = self.query_one("#title-input", Input)
968
+ new_title = input_widget.value.strip()
969
+ if new_title:
970
+ # Call the app's update method asynchronously
971
+ asyncio.create_task(self.app.update_conversation_title(new_title))
972
+ self.remove() # Close the modal
973
+
974
+ @on(Button.Pressed, "#cancel-button")
975
+ def cancel(self, event: Button.Pressed) -> None:
976
+ self.remove() # Close the modal
977
+
978
+ async def on_mount(self) -> None:
979
+ """Focus the input when the modal appears."""
980
+ self.query_one("#title-input", Input).focus()
884
981
 
885
982
  # --- Show the modal ---
886
983
  modal = TitleInputModal(self.current_conversation.title)
887
984
  await self.mount(modal) # Use await for mounting
888
985
 
986
+ async def run_modal(self, modal_type: str, *args, **kwargs) -> bool:
987
+ """Run a modal dialog and return the result."""
988
+ if modal_type == "confirm_dialog":
989
+ # Create a confirmation dialog with the message from args
990
+ message = args[0] if args else "Are you sure?"
991
+ dialog = ConfirmDialog(message)
992
+ await self.mount(dialog)
993
+
994
+ # Setup event handler to receive the result
995
+ result = False
996
+
997
+ def on_confirm(event: ConfirmDialog.Confirmed) -> None:
998
+ nonlocal result
999
+ result = event.confirmed
1000
+
1001
+ # Add listener for the confirmation event
1002
+ dialog.on_confirmed = on_confirm
1003
+
1004
+ # Wait for the dialog to close
1005
+ while dialog.is_mounted:
1006
+ await self.sleep(0.1)
1007
+
1008
+ return result
1009
+
1010
+ return False
1011
+
889
1012
  async def update_conversation_title(self, new_title: str) -> None:
890
1013
  """Update the current conversation title"""
891
1014
  if not self.current_conversation:
@@ -931,4 +1054,4 @@ def main(initial_text: Optional[str] = typer.Argument(None, help="Initial text t
931
1054
  app.run() # Keep main function
932
1055
 
933
1056
  if __name__ == "__main__": # Keep main function entry point
934
- typer.run(main) # Keep main function entry point
1057
+ typer.run(main) # Keep main function entry point