import os import sys import shutil import click from datetime import datetime from typing import List # --- Core Logic Imports --- # These modules contain the application's functionality. from src.config import AppConfig from src.hn_mood_reader import HnMoodReader, FeedEntry from src.vibe_logic import VIBE_THRESHOLDS # --- Helper Functions --- def get_status_text_and_color(score: float) -> (str, str): """ Determines the plain text status and a corresponding color for a given score. """ clamped_score = max(0.0, min(1.0, score)) # Define colors for different vibe levels color_map = { "VIBE:HIGH": "green", "VIBE:GOOD": "cyan", "VIBE:FLAT": "yellow", "VIBE:LOW": "red" } for threshold in VIBE_THRESHOLDS: if clamped_score >= threshold.score: status = threshold.status.split(" ")[-1].replace(' ', '') return status, color_map.get(status, "white") # Fallback for the lowest score status = VIBE_THRESHOLDS[-1].status.split(" ")[-1].replace(' ', '') return status, color_map.get(status, "white") def initialize_reader(model_name: str) -> HnMoodReader: """ Initializes the HnMoodReader instance with the specified model. Exits the script if the model fails to load. """ click.echo(f"Initializing mood reader with model: '{model_name}'...", err=True) try: reader = HnMoodReader(model_name=model_name) click.secho("✅ Model loaded successfully.", fg="green", err=True) return reader except Exception as e: click.secho(f"❌ FATAL: Could not initialize model '{model_name}'.", fg="red", err=True) click.secho(f" Error: {e}", fg="red", err=True) sys.exit(1) # Exit with a non-zero code to indicate failure def display_feed(scored_entries: List[FeedEntry], top: int, offset: int, model_name: str): """Clears the screen and displays the current slice of the feed.""" click.clear() # Get terminal width, but default to 80 if it's too narrow # to avoid breaking the layout. try: terminal_width = shutil.get_terminal_size()[0] except OSError: # Handle cases where terminal size can't be determined (e.g., in a pipe) terminal_width = 80 click.echo(f"📰 Hacker News Mood Reader") click.echo(f" Model: {model_name}") click.echo(f" Showing {offset + 1}-{min(offset + top, len(scored_entries))} of {len(scored_entries)} stories") click.secho("=" * terminal_width, fg="blue") header = f"{'VIBE':<5} | {'SCORE':<7} | {'PUBLISHED':<16} | {'TITLE'}" click.secho(header, bold=True) click.secho("-" * terminal_width, fg="blue") # Calculate the fixed width of the columns before the title # Vibe: 5 # Score: | + ' ' + '0.0000' + ' ' = 9 # Published: | + ' ' + 'YYYY-MM-DD HH:MM' + ' ' + | + ' ' = 21 # Total fixed width = 5 + 9 + 21 = 35 fixed_width = 35 max_title_width = terminal_width - fixed_width # --- MODIFICATION END --- if not scored_entries: click.echo("No entries found in the feed.") else: # Display the current "page" of entries based on the offset for entry in scored_entries[offset:offset + top]: status, color = get_status_text_and_color(entry.mood.raw_score) # --- MODIFICATION: VIBE width changed from 12 to 5 --- # Also ensure the status text itself is truncated if it's longer than 5 truncated_status = status[5:] vibe_part = click.style(f"{truncated_status:<5}", fg=color) score_part = f"| {entry.mood.raw_score:>.4f} " published_part = f"| {entry.published_time_str:<16} | " # --- Title Truncation Logic --- full_title = entry.title if len(full_title) > max_title_width: # Truncate and add ellipsis, reserving 3 chars for '...' title_part = full_title[:max_title_width - 3] + "..." else: title_part = full_title # --- End Title Truncation --- # Combine parts and print full_line = vibe_part + score_part + published_part + title_part click.echo(full_line) click.secho("-" * terminal_width, fg="blue") # --- Main Application Logic (CLI Command) --- @click.command() @click.option( "-m", "--model", help="Name of the Sentence Transformer model from Hugging Face. Overrides MOOD_MODEL env var.", default=None, show_default=False ) @click.option( "-n", "--top", help="Number of stories to display on screen at once.", default=15, type=int, show_default=True ) def main(model, top): """ Fetch and display Hacker News stories scored by a sentence-embedding model. Runs continuously. Use arrow keys to scroll, [SPACE] to refresh, [q] to quit. """ # --- State Management --- model_name = model or os.environ.get("MOOD_MODEL") or AppConfig.DEFAULT_MOOD_READER_MODEL reader = initialize_reader(model_name) scored_entries: List[FeedEntry] = [] scroll_offset = 0 # --- Initial Fetch --- click.echo("Fetching initial feed...", err=True) try: scored_entries = reader.fetch_and_score_feed() except Exception as e: click.secho(f"❌ ERROR: Initial fetch failed: {e}", fg="red", err=True) # --- Main Loop --- while True: display_feed(scored_entries, top, scroll_offset, reader.model_name) click.secho("Use [↑|↓] to scroll, [SPACE] to refresh, or [q] to quit.", bold=True, err=True) key = click.getchar() if key == ' ': click.echo("Refreshing feed...", err=True) try: scored_entries = reader.fetch_and_score_feed() scroll_offset = 0 # Reset scroll on refresh except Exception as e: click.secho(f"❌ ERROR: Refresh failed: {e}", fg="red", err=True) continue elif key in ('q', 'Q'): click.echo("Exiting.") break # Arrow key handling for scrolling (might produce escape sequences) elif key == '\x1b[A': # Up Arrow scroll_offset = max(0, scroll_offset - 1) elif key == '\x1b[B': # Down Arrow # Prevent scrolling past the last page scroll_offset = min(scroll_offset + 1, max(0, len(scored_entries) - top)) if __name__ == "__main__": main()