File size: 6,507 Bytes
64ae41c
 
 
 
 
 
 
 
 
beabfb7
 
 
64ae41c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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()