| 1 |
"""Terminal UI utilities for last30days skill.""" |
| 2 |
|
| 3 |
import sys |
| 4 |
import time |
| 5 |
import threading |
| 6 |
import random |
| 7 |
from typing import Optional |
| 8 |
|
| 9 |
from .render import _skill_version |
| 10 |
|
| 11 |
# Check if we're in a real terminal (not captured by Claude Code) |
| 12 |
IS_TTY = sys.stderr.isatty() |
| 13 |
|
| 14 |
# ANSI color codes |
| 15 |
class Colors: |
| 16 |
PURPLE = '\033[95m' |
| 17 |
BLUE = '\033[94m' |
| 18 |
CYAN = '\033[96m' |
| 19 |
GREEN = '\033[92m' |
| 20 |
YELLOW = '\033[93m' |
| 21 |
RED = '\033[91m' |
| 22 |
BOLD = '\033[1m' |
| 23 |
DIM = '\033[2m' |
| 24 |
RESET = '\033[0m' |
| 25 |
|
| 26 |
|
| 27 |
BANNER = f"""{Colors.PURPLE}{Colors.BOLD} |
| 28 |
██╗ █████╗ ███████╗████████╗██████╗ ██████╗ ██████╗ █████╗ ██╗ ██╗███████╗ |
| 29 |
██║ ██╔══██╗██╔════╝╚══██╔══╝╚════██╗██╔═████╗██╔══██╗██╔══██╗╚██╗ ██╔╝██╔════╝ |
| 30 |
██║ ███████║███████╗ ██║ █████╔╝██║██╔██║██║ ██║███████║ ╚████╔╝ ███████╗ |
| 31 |
██║ ██╔══██║╚════██║ ██║ ╚═══██╗████╔╝██║██║ ██║██╔══██║ ╚██╔╝ ╚════██║ |
| 32 |
███████╗██║ ██║███████║ ██║ ██████╔╝╚██████╔╝██████╔╝██║ ██║ ██║ ███████║ |
| 33 |
╚══════╝╚═╝ ╚═╝╚══════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ |
| 34 |
{Colors.RESET}{Colors.DIM} 30 days of research. 30 seconds of work.{Colors.RESET} |
| 35 |
""" |
| 36 |
|
| 37 |
MINI_BANNER = f"""{Colors.PURPLE}{Colors.BOLD}/last30days{Colors.RESET} {Colors.DIM}· researching...{Colors.RESET}""" |
| 38 |
|
| 39 |
# Fun status messages for each phase |
| 40 |
REDDIT_MESSAGES = [ |
| 41 |
"Diving into Reddit threads...", |
| 42 |
"Scanning subreddits for gold...", |
| 43 |
"Reading what Redditors are saying...", |
| 44 |
"Exploring the front page of the internet...", |
| 45 |
"Finding the good discussions...", |
| 46 |
"Upvoting mentally...", |
| 47 |
"Scrolling through comments...", |
| 48 |
] |
| 49 |
|
| 50 |
X_MESSAGES = [ |
| 51 |
"Checking what X is buzzing about...", |
| 52 |
"Reading the timeline...", |
| 53 |
"Finding the hot takes...", |
| 54 |
"Scanning tweets and threads...", |
| 55 |
"Discovering trending insights...", |
| 56 |
"Following the conversation...", |
| 57 |
"Reading between the posts...", |
| 58 |
] |
| 59 |
|
| 60 |
ENRICHING_MESSAGES = [ |
| 61 |
"Getting the juicy details...", |
| 62 |
"Fetching engagement metrics...", |
| 63 |
"Reading top comments...", |
| 64 |
"Extracting insights...", |
| 65 |
"Analyzing discussions...", |
| 66 |
] |
| 67 |
|
| 68 |
YOUTUBE_MESSAGES = [ |
| 69 |
"Searching YouTube for videos...", |
| 70 |
"Finding relevant video content...", |
| 71 |
"Scanning YouTube channels...", |
| 72 |
"Discovering video discussions...", |
| 73 |
"Fetching transcripts...", |
| 74 |
] |
| 75 |
|
| 76 |
TIKTOK_MESSAGES = [ |
| 77 |
"Searching TikTok for trending videos...", |
| 78 |
"Finding what's viral on TikTok...", |
| 79 |
"Scanning TikTok for relevant content...", |
| 80 |
] |
| 81 |
|
| 82 |
INSTAGRAM_MESSAGES = [ |
| 83 |
"Searching Instagram Reels...", |
| 84 |
"Finding what's trending on Instagram...", |
| 85 |
"Scanning Instagram for relevant reels...", |
| 86 |
] |
| 87 |
|
| 88 |
HN_MESSAGES = [ |
| 89 |
"Searching Hacker News...", |
| 90 |
"Scanning HN front page stories...", |
| 91 |
"Finding technical discussions...", |
| 92 |
"Discovering developer conversations...", |
| 93 |
] |
| 94 |
|
| 95 |
POLYMARKET_MESSAGES = [ |
| 96 |
"Checking prediction markets...", |
| 97 |
"Finding what people are betting on...", |
| 98 |
"Scanning Polymarket for odds...", |
| 99 |
"Discovering prediction markets...", |
| 100 |
] |
| 101 |
|
| 102 |
PROCESSING_MESSAGES = [ |
| 103 |
"Crunching the data...", |
| 104 |
"Scoring and ranking...", |
| 105 |
"Finding patterns...", |
| 106 |
"Removing duplicates...", |
| 107 |
"Organizing findings...", |
| 108 |
] |
| 109 |
|
| 110 |
WEB_ONLY_MESSAGES = [ |
| 111 |
"Searching the web...", |
| 112 |
"Finding blogs and docs...", |
| 113 |
"Crawling news sites...", |
| 114 |
"Discovering tutorials...", |
| 115 |
] |
| 116 |
|
| 117 |
SOURCE_COMPLETION_ORDER = [ |
| 118 |
"reddit", |
| 119 |
"x", |
| 120 |
"youtube", |
| 121 |
"tiktok", |
| 122 |
"instagram", |
| 123 |
"hackernews", |
| 124 |
"bluesky", |
| 125 |
"truthsocial", |
| 126 |
"polymarket", |
| 127 |
"grounding", |
| 128 |
"xiaohongshu", |
| 129 |
"digg", |
| 130 |
"arxiv", |
| 131 |
"techmeme", |
| 132 |
"trustpilot", |
| 133 |
] |
| 134 |
|
| 135 |
SOURCE_COMPLETION_META = { |
| 136 |
"reddit": ("Reddit", "thread", "threads", Colors.YELLOW), |
| 137 |
"x": ("X", "post", "posts", Colors.CYAN), |
| 138 |
"youtube": ("YouTube", "video", "videos", Colors.RED), |
| 139 |
"tiktok": ("TikTok", "video", "videos", Colors.PURPLE), |
| 140 |
"instagram": ("Instagram", "reel", "reels", Colors.PURPLE), |
| 141 |
"hackernews": ("HN", "story", "stories", Colors.YELLOW), |
| 142 |
"bluesky": ("Bluesky", "post", "posts", Colors.BLUE), |
| 143 |
"truthsocial": ("Truth Social", "post", "posts", Colors.CYAN), |
| 144 |
"polymarket": ("Polymarket", "market", "markets", Colors.GREEN), |
| 145 |
"grounding": ("Web", "result", "results", Colors.GREEN), |
| 146 |
"xiaohongshu": ("Xiaohongshu", "post", "posts", Colors.RED), |
| 147 |
"digg": ("Digg", "cluster", "clusters", Colors.YELLOW), |
| 148 |
"arxiv": ("arXiv", "paper", "papers", Colors.RED), |
| 149 |
"techmeme": ("Techmeme", "headline", "headlines", Colors.CYAN), |
| 150 |
"trustpilot": ("Trustpilot", "review", "reviews", Colors.GREEN), |
| 151 |
} |
| 152 |
|
| 153 |
|
| 154 |
def _completion_sources(source_counts: dict[str, int], display_sources: list[str] | None) -> list[str]: |
| 155 |
requested = list(dict.fromkeys(display_sources or [])) |
| 156 |
if not requested: |
| 157 |
requested = [source for source, count in source_counts.items() if count] |
| 158 |
if not requested and source_counts: |
| 159 |
requested = list(source_counts) |
| 160 |
|
| 161 |
candidate_set = set(requested) | set(source_counts) |
| 162 |
ordered = [source for source in SOURCE_COMPLETION_ORDER if source in candidate_set] |
| 163 |
for source in requested + list(source_counts): |
| 164 |
if source in candidate_set and source not in ordered: |
| 165 |
ordered.append(source) |
| 166 |
return ordered |
| 167 |
|
| 168 |
|
| 169 |
def _format_completion_part(source: str, count: int, tty: bool) -> str: |
| 170 |
label, singular, plural, color = SOURCE_COMPLETION_META.get( |
| 171 |
source, |
| 172 |
(source.replace("_", " ").title(), "result", "results", Colors.RESET), |
| 173 |
) |
| 174 |
unit = singular if count == 1 else plural |
| 175 |
if tty: |
| 176 |
return f"{color}{label}:{Colors.RESET} {count} {unit}" |
| 177 |
return f"{label}: {count} {unit}" |
| 178 |
|
| 179 |
def _build_nux_message(diag: dict = None) -> str: |
| 180 |
"""Build conversational NUX message with dynamic source status.""" |
| 181 |
available = set((diag or {}).get("available_sources", [])) |
| 182 |
if diag: |
| 183 |
reddit = "✓" if "reddit" in available else "✗" |
| 184 |
x = "✓" if "x" in available else "✗" |
| 185 |
youtube = "✓" if "youtube" in available else "✗" |
| 186 |
web = "✓" if "grounding" in available else "✗" |
| 187 |
status_line = f"Reddit {reddit}, X {x}, YouTube {youtube}, Web {web}" |
| 188 |
else: |
| 189 |
status_line = "YouTube ✓, Web ✓, Reddit ✗, X ✗" |
| 190 |
|
| 191 |
return f""" |
| 192 |
I just researched that for you. Here's what I've got right now: |
| 193 |
|
| 194 |
{status_line} |
| 195 |
|
| 196 |
More sources means better research, but it works fine as-is. You can unlock more for free - log into x.com in your browser for X, and run `brew install yt-dlp` for YouTube transcripts. That gives you Reddit (with comments), X, YouTube, HN, and Polymarket - all free. |
| 197 |
|
| 198 |
Some examples of what you can do: |
| 199 |
- "last30 what are people saying about Figma" |
| 200 |
- "last30 watch my biggest competitor every week" |
| 201 |
- "last30 watch AI video tools monthly" |
| 202 |
- "last30 what have you found about AI video?" |
| 203 |
|
| 204 |
Just start with "last30" and talk to me like normal. |
| 205 |
""" |
| 206 |
|
| 207 |
# Shorter promo for single missing key |
| 208 |
PROMO_SINGLE_KEY = { |
| 209 |
"reddit": "\n💡 Unlock TikTok and Instagram with SCRAPECREATORS_API_KEY - 10,000 free calls, no CC - scrapecreators.com\n", |
| 210 |
"x": "\n💡 Unlock X: log into x.com in your browser, then re-run. " |
| 211 |
"Firefox works on all platforms. Safari works on macOS (detected automatically). " |
| 212 |
"Chrome, Brave, Edge, Arc, Vivaldi, Opera, or Chromium on macOS require " |
| 213 |
"FROM_BROWSER=auto in .env (Keychain dialog). On Windows only Firefox is supported. " |
| 214 |
"Or add AUTH_TOKEN/CT0 or XAI_API_KEY.\n", |
| 215 |
"web": "\n💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY.\n", |
| 216 |
} |
| 217 |
|
| 218 |
# Bird auth help (for local users with vendored Bird CLI) |
| 219 |
BIRD_AUTH_HELP = f""" |
| 220 |
{Colors.YELLOW}Bird authentication failed.{Colors.RESET} |
| 221 |
|
| 222 |
To fix this: |
| 223 |
1. Add AUTH_TOKEN and CT0 to ~/.config/last30days/.env, or to trusted .claude/last30days.env with LAST30DAYS_TRUST_PROJECT_CONFIG=1 |
| 224 |
2. Or set XAI_API_KEY for the xAI fallback backend |
| 225 |
""" |
| 226 |
|
| 227 |
BIRD_AUTH_HELP_PLAIN = """ |
| 228 |
Bird authentication failed. |
| 229 |
|
| 230 |
To fix this: |
| 231 |
1. Add AUTH_TOKEN and CT0 to ~/.config/last30days/.env, or to trusted .claude/last30days.env with LAST30DAYS_TRUST_PROJECT_CONFIG=1 |
| 232 |
2. Or set XAI_API_KEY for the xAI fallback backend |
| 233 |
""" |
| 234 |
|
| 235 |
# Spinner frames |
| 236 |
SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] |
| 237 |
DOTS_FRAMES = [' ', '. ', '.. ', '...'] |
| 238 |
|
| 239 |
|
| 240 |
class Spinner: |
| 241 |
"""Animated spinner for long-running operations.""" |
| 242 |
|
| 243 |
def __init__(self, message: str = "Working", color: str = Colors.CYAN, quiet: bool = False): |
| 244 |
self.message = message |
| 245 |
self.color = color |
| 246 |
self.running = False |
| 247 |
self.thread: Optional[threading.Thread] = None |
| 248 |
self.frame_idx = 0 |
| 249 |
self.shown_static = False |
| 250 |
self.quiet = quiet # Suppress non-TTY start message (still shows ✓ completion) |
| 251 |
|
| 252 |
def _spin(self): |
| 253 |
while self.running: |
| 254 |
frame = SPINNER_FRAMES[self.frame_idx % len(SPINNER_FRAMES)] |
| 255 |
sys.stderr.write(f"\r{self.color}{frame}{Colors.RESET} {self.message} ") |
| 256 |
sys.stderr.flush() |
| 257 |
self.frame_idx += 1 |
| 258 |
time.sleep(0.08) |
| 259 |
|
| 260 |
def start(self): |
| 261 |
self.running = True |
| 262 |
if IS_TTY: |
| 263 |
# Real terminal - animate |
| 264 |
self.thread = threading.Thread(target=self._spin, daemon=True) |
| 265 |
self.thread.start() |
| 266 |
else: |
| 267 |
# Not a TTY (Claude Code) - just print once |
| 268 |
if not self.shown_static and not self.quiet: |
| 269 |
sys.stderr.write(f"⏳ {self.message}\n") |
| 270 |
sys.stderr.flush() |
| 271 |
self.shown_static = True |
| 272 |
|
| 273 |
def update(self, message: str): |
| 274 |
self.message = message |
| 275 |
if not IS_TTY and not self.shown_static: |
| 276 |
# Print update in non-TTY mode |
| 277 |
sys.stderr.write(f"⏳ {message}\n") |
| 278 |
sys.stderr.flush() |
| 279 |
|
| 280 |
def stop(self, final_message: str = ""): |
| 281 |
self.running = False |
| 282 |
if self.thread: |
| 283 |
self.thread.join(timeout=0.2) |
| 284 |
if IS_TTY: |
| 285 |
# Clear the line in real terminal |
| 286 |
sys.stderr.write("\r" + " " * 80 + "\r") |
| 287 |
if final_message: |
| 288 |
sys.stderr.write(f"✓ {final_message}\n") |
| 289 |
sys.stderr.flush() |
| 290 |
|
| 291 |
|
| 292 |
class ProgressDisplay: |
| 293 |
"""Progress display for research phases.""" |
| 294 |
|
| 295 |
def __init__(self, topic: str, show_banner: bool = True): |
| 296 |
self.topic = topic |
| 297 |
self.spinner: Optional[Spinner] = None |
| 298 |
self.start_time = time.time() |
| 299 |
|
| 300 |
if show_banner: |
| 301 |
self._show_banner() |
| 302 |
|
| 303 |
def _show_banner(self): |
| 304 |
if IS_TTY: |
| 305 |
sys.stderr.write(MINI_BANNER + "\n") |
| 306 |
sys.stderr.write(f"{Colors.DIM}Topic: {Colors.RESET}{Colors.BOLD}{self.topic}{Colors.RESET}\n\n") |
| 307 |
else: |
| 308 |
# Simple text for non-TTY |
| 309 |
sys.stderr.write(f"/last30days · researching: {self.topic}\n") |
| 310 |
sys.stderr.flush() |
| 311 |
|
| 312 |
def start_reddit(self): |
| 313 |
msg = random.choice(REDDIT_MESSAGES) |
| 314 |
self.spinner = Spinner(f"{Colors.YELLOW}Reddit{Colors.RESET} {msg}", Colors.YELLOW) |
| 315 |
self.spinner.start() |
| 316 |
|
| 317 |
def end_reddit(self, count: int): |
| 318 |
if self.spinner: |
| 319 |
self.spinner.stop(f"{Colors.YELLOW}Reddit{Colors.RESET} Found {count} threads") |
| 320 |
|
| 321 |
def start_reddit_enrich(self, current: int, total: int): |
| 322 |
if self.spinner: |
| 323 |
self.spinner.stop() |
| 324 |
msg = random.choice(ENRICHING_MESSAGES) |
| 325 |
self.spinner = Spinner(f"{Colors.YELLOW}Reddit{Colors.RESET} [{current}/{total}] {msg}", Colors.YELLOW) |
| 326 |
self.spinner.start() |
| 327 |
|
| 328 |
def update_reddit_enrich(self, current: int, total: int): |
| 329 |
if self.spinner: |
| 330 |
msg = random.choice(ENRICHING_MESSAGES) |
| 331 |
self.spinner.update(f"{Colors.YELLOW}Reddit{Colors.RESET} [{current}/{total}] {msg}") |
| 332 |
|
| 333 |
def end_reddit_enrich(self): |
| 334 |
if self.spinner: |
| 335 |
self.spinner.stop(f"{Colors.YELLOW}Reddit{Colors.RESET} Enriched with engagement data") |
| 336 |
|
| 337 |
def start_x(self): |
| 338 |
msg = random.choice(X_MESSAGES) |
| 339 |
self.spinner = Spinner(f"{Colors.CYAN}X{Colors.RESET} {msg}", Colors.CYAN) |
| 340 |
self.spinner.start() |
| 341 |
|
| 342 |
def end_x(self, count: int): |
| 343 |
if self.spinner: |
| 344 |
self.spinner.stop(f"{Colors.CYAN}X{Colors.RESET} Found {count} posts") |
| 345 |
|
| 346 |
def start_youtube(self): |
| 347 |
msg = random.choice(YOUTUBE_MESSAGES) |
| 348 |
self.spinner = Spinner(f"{Colors.RED}YouTube{Colors.RESET} {msg}", Colors.RED) |
| 349 |
self.spinner.start() |
| 350 |
|
| 351 |
def end_youtube(self, count: int): |
| 352 |
if self.spinner: |
| 353 |
self.spinner.stop(f"{Colors.RED}YouTube{Colors.RESET} Found {count} videos") |
| 354 |
|
| 355 |
def start_tiktok(self): |
| 356 |
msg = random.choice(TIKTOK_MESSAGES) |
| 357 |
self.spinner = Spinner(f"{Colors.PURPLE}TikTok{Colors.RESET} {msg}", Colors.PURPLE) |
| 358 |
self.spinner.start() |
| 359 |
|
| 360 |
def end_tiktok(self, count: int): |
| 361 |
if self.spinner: |
| 362 |
self.spinner.stop(f"{Colors.PURPLE}TikTok{Colors.RESET} Found {count} videos") |
| 363 |
|
| 364 |
def start_instagram(self): |
| 365 |
msg = random.choice(INSTAGRAM_MESSAGES) |
| 366 |
self.spinner = Spinner(f"{Colors.PURPLE}Instagram{Colors.RESET} {msg}", Colors.PURPLE) |
| 367 |
self.spinner.start() |
| 368 |
|
| 369 |
def end_instagram(self, count: int): |
| 370 |
if self.spinner: |
| 371 |
self.spinner.stop(f"{Colors.PURPLE}Instagram{Colors.RESET} Found {count} reels") |
| 372 |
|
| 373 |
def start_hackernews(self): |
| 374 |
msg = random.choice(HN_MESSAGES) |
| 375 |
self.spinner = Spinner(f"{Colors.YELLOW}HN{Colors.RESET} {msg}", Colors.YELLOW, quiet=True) |
| 376 |
self.spinner.start() |
| 377 |
|
| 378 |
def end_hackernews(self, count: int): |
| 379 |
if self.spinner: |
| 380 |
self.spinner.stop(f"{Colors.YELLOW}HN{Colors.RESET} Found {count} stories") |
| 381 |
|
| 382 |
def start_polymarket(self): |
| 383 |
msg = random.choice(POLYMARKET_MESSAGES) |
| 384 |
self.spinner = Spinner(f"{Colors.GREEN}Polymarket{Colors.RESET} {msg}", Colors.GREEN, quiet=True) |
| 385 |
self.spinner.start() |
| 386 |
|
| 387 |
def end_polymarket(self, count: int): |
| 388 |
if self.spinner: |
| 389 |
self.spinner.stop(f"{Colors.GREEN}Polymarket{Colors.RESET} Found {count} markets") |
| 390 |
|
| 391 |
def start_processing(self): |
| 392 |
msg = random.choice(PROCESSING_MESSAGES) |
| 393 |
self.spinner = Spinner(f"{Colors.PURPLE}Processing{Colors.RESET} {msg}", Colors.PURPLE) |
| 394 |
self.spinner.start() |
| 395 |
|
| 396 |
def end_processing(self): |
| 397 |
if self.spinner: |
| 398 |
self.spinner.stop() |
| 399 |
|
| 400 |
def show_complete( |
| 401 |
self, |
| 402 |
reddit_count: int = 0, |
| 403 |
x_count: int = 0, |
| 404 |
youtube_count: int = 0, |
| 405 |
hn_count: int = 0, |
| 406 |
pm_count: int = 0, |
| 407 |
tiktok_count: int = 0, |
| 408 |
ig_count: int = 0, |
| 409 |
*, |
| 410 |
source_counts: dict[str, int] | None = None, |
| 411 |
display_sources: list[str] | None = None, |
| 412 |
): |
| 413 |
elapsed = time.time() - self.start_time |
| 414 |
if source_counts is None: |
| 415 |
source_counts = { |
| 416 |
"reddit": reddit_count, |
| 417 |
"x": x_count, |
| 418 |
"youtube": youtube_count, |
| 419 |
"tiktok": tiktok_count, |
| 420 |
"instagram": ig_count, |
| 421 |
"hackernews": hn_count, |
| 422 |
"polymarket": pm_count, |
| 423 |
} |
| 424 |
if display_sources is None: |
| 425 |
display_sources = [source for source, count in source_counts.items() if count] |
| 426 |
if not display_sources: |
| 427 |
display_sources = ["reddit", "x"] |
| 428 |
|
| 429 |
ordered_sources = _completion_sources(source_counts, display_sources) |
| 430 |
parts = [ |
| 431 |
_format_completion_part(source, source_counts.get(source, 0), tty=IS_TTY) |
| 432 |
for source in ordered_sources |
| 433 |
] |
| 434 |
if IS_TTY: |
| 435 |
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Research complete{Colors.RESET} ") |
| 436 |
sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n") |
| 437 |
sys.stderr.write(" " + " ".join(parts)) |
| 438 |
sys.stderr.write("\n\n") |
| 439 |
else: |
| 440 |
sys.stderr.write(f"✓ Research complete ({elapsed:.1f}s) - {', '.join(parts)}\n") |
| 441 |
sys.stderr.flush() |
| 442 |
|
| 443 |
def show_cached(self, age_hours: float = None): |
| 444 |
if age_hours is not None: |
| 445 |
age_str = f" ({age_hours:.1f}h old)" |
| 446 |
else: |
| 447 |
age_str = "" |
| 448 |
sys.stderr.write(f"{Colors.GREEN}⚡{Colors.RESET} {Colors.DIM}Using cached results{age_str} - use --refresh for fresh data{Colors.RESET}\n\n") |
| 449 |
sys.stderr.flush() |
| 450 |
|
| 451 |
def show_error(self, message: str): |
| 452 |
sys.stderr.write(f"{Colors.RED}✗ Error:{Colors.RESET} {message}\n") |
| 453 |
sys.stderr.flush() |
| 454 |
|
| 455 |
def start_web_only(self): |
| 456 |
"""Show web-only mode indicator.""" |
| 457 |
msg = random.choice(WEB_ONLY_MESSAGES) |
| 458 |
self.spinner = Spinner(f"{Colors.GREEN}Web{Colors.RESET} {msg}", Colors.GREEN) |
| 459 |
self.spinner.start() |
| 460 |
|
| 461 |
def end_web_only(self): |
| 462 |
"""End web-only spinner.""" |
| 463 |
if self.spinner: |
| 464 |
self.spinner.stop(f"{Colors.GREEN}Web{Colors.RESET} assistant will search the web") |
| 465 |
|
| 466 |
def show_web_only_complete(self): |
| 467 |
"""Show completion for web-only mode.""" |
| 468 |
elapsed = time.time() - self.start_time |
| 469 |
if IS_TTY: |
| 470 |
sys.stderr.write(f"\n{Colors.GREEN}{Colors.BOLD}✓ Ready for web search{Colors.RESET} ") |
| 471 |
sys.stderr.write(f"{Colors.DIM}({elapsed:.1f}s){Colors.RESET}\n") |
| 472 |
sys.stderr.write(f" {Colors.GREEN}Web:{Colors.RESET} assistant will search blogs, docs & news\n\n") |
| 473 |
else: |
| 474 |
sys.stderr.write(f"✓ Ready for web search ({elapsed:.1f}s)\n") |
| 475 |
sys.stderr.flush() |
| 476 |
|
| 477 |
def show_promo(self, missing: str = "both", diag: dict = None): |
| 478 |
"""Show NUX / promotional message for missing API keys. |
| 479 |
|
| 480 |
Args: |
| 481 |
missing: 'both', 'all', 'reddit', or 'x' - which keys are missing |
| 482 |
diag: Optional diagnostics dict for dynamic source status |
| 483 |
""" |
| 484 |
if missing in ("both", "all"): |
| 485 |
sys.stderr.write(_build_nux_message(diag)) |
| 486 |
elif missing in PROMO_SINGLE_KEY: |
| 487 |
sys.stderr.write(PROMO_SINGLE_KEY[missing]) |
| 488 |
sys.stderr.flush() |
| 489 |
|
| 490 |
def show_bird_auth_help(self): |
| 491 |
"""Show Bird authentication help.""" |
| 492 |
if IS_TTY: |
| 493 |
sys.stderr.write(BIRD_AUTH_HELP) |
| 494 |
else: |
| 495 |
sys.stderr.write(BIRD_AUTH_HELP_PLAIN) |
| 496 |
sys.stderr.flush() |
| 497 |
|
| 498 |
|
| 499 |
def show_diagnostic_banner(diag: dict): |
| 500 |
"""Show pre-flight source status banner when sources are missing. |
| 501 |
|
| 502 |
Args: |
| 503 |
diag: Dict from pipeline.diagnose() with available_sources, x_backend, |
| 504 |
bird status, provider availability, and native web backend info. |
| 505 |
""" |
| 506 |
available_sources = set(diag.get("available_sources") or []) |
| 507 |
has_reddit = "reddit" in available_sources |
| 508 |
has_scrapecreators = diag.get("has_scrapecreators", False) |
| 509 |
has_x = "x" in available_sources |
| 510 |
has_youtube = "youtube" in available_sources |
| 511 |
has_web = "grounding" in available_sources |
| 512 |
has_xiaohongshu = "xiaohongshu" in available_sources |
| 513 |
x_backend = diag.get("x_backend") |
| 514 |
native_web_backend = diag.get("native_web_backend") |
| 515 |
|
| 516 |
# If everything is available, no banner needed |
| 517 |
if has_reddit and has_x and has_youtube and has_web: |
| 518 |
return |
| 519 |
|
| 520 |
lines = [] |
| 521 |
|
| 522 |
if IS_TTY: |
| 523 |
lines.append(f"{Colors.DIM}┌─────────────────────────────────────────────────────┐{Colors.RESET}") |
| 524 |
_header = f"/last30days v{_skill_version()} - Source Status" |
| 525 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.BOLD}{_header}{Colors.RESET}{' ' * (52 - len(_header))}{Colors.DIM}│{Colors.RESET}") |
| 526 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.DIM}│{Colors.RESET}") |
| 527 |
|
| 528 |
# Reddit |
| 529 |
if has_reddit and has_scrapecreators: |
| 530 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — full threads with comments {Colors.DIM}│{Colors.RESET}") |
| 531 |
elif has_reddit: |
| 532 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Reddit{Colors.RESET} — public threads (titles + scores) {Colors.DIM}│{Colors.RESET}") |
| 533 |
else: |
| 534 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ Reddit{Colors.RESET} — unavailable {Colors.DIM}│{Colors.RESET}") |
| 535 |
|
| 536 |
# X/Twitter |
| 537 |
if has_x: |
| 538 |
username = diag.get("bird_username", "") |
| 539 |
label = f"Bird ({username})" if x_backend == "bird" and username else str(x_backend or "xai").upper() |
| 540 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ X/Twitter{Colors.RESET} — {label} {Colors.DIM}│{Colors.RESET}") |
| 541 |
else: |
| 542 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ X/Twitter{Colors.RESET} — No X auth or fallback key {Colors.DIM}│{Colors.RESET}") |
| 543 |
if diag.get("bird_installed"): |
| 544 |
lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Add AUTH_TOKEN/CT0 or XAI_API_KEY {Colors.DIM}│{Colors.RESET}") |
| 545 |
else: |
| 546 |
lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Needs Node.js 22+ (Bird is bundled) {Colors.DIM}│{Colors.RESET}") |
| 547 |
|
| 548 |
# YouTube |
| 549 |
if has_youtube: |
| 550 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ YouTube{Colors.RESET} — yt-dlp found {Colors.DIM}│{Colors.RESET}") |
| 551 |
else: |
| 552 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.RED}❌ YouTube{Colors.RESET} — yt-dlp not installed {Colors.DIM}│{Colors.RESET}") |
| 553 |
lines.append(f"{Colors.DIM}│{Colors.RESET} └─ Fix: brew install yt-dlp (free) {Colors.DIM}│{Colors.RESET}") |
| 554 |
|
| 555 |
# Xiaohongshu (only show when configured) |
| 556 |
if has_xiaohongshu: |
| 557 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Xiaohongshu{Colors.RESET} — API connected + logged in {Colors.DIM}│{Colors.RESET}") |
| 558 |
|
| 559 |
# Web |
| 560 |
if has_web: |
| 561 |
backend = native_web_backend or "native" |
| 562 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.GREEN}✅ Web{Colors.RESET} — {backend} API {Colors.DIM}│{Colors.RESET}") |
| 563 |
else: |
| 564 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.YELLOW}⚡ Web{Colors.RESET} — Add BRAVE_API_KEY or SERPER_API_KEY {Colors.DIM}│{Colors.RESET}") |
| 565 |
|
| 566 |
lines.append(f"{Colors.DIM}│{Colors.RESET} {Colors.DIM}│{Colors.RESET}") |
| 567 |
lines.append(f"{Colors.DIM}│{Colors.RESET} Config: {Colors.BOLD}~/.config/last30days/.env{Colors.RESET} {Colors.DIM}│{Colors.RESET}") |
| 568 |
lines.append(f"{Colors.DIM}└─────────────────────────────────────────────────────┘{Colors.RESET}") |
| 569 |
else: |
| 570 |
# Plain text for non-TTY (Claude Code / Codex) |
| 571 |
lines.append("┌─────────────────────────────────────────────────────┐") |
| 572 |
_header_plain = f"/last30days v{_skill_version()} - Source Status" |
| 573 |
lines.append(f"│ {_header_plain}{' ' * (52 - len(_header_plain))}│") |
| 574 |
lines.append("│ │") |
| 575 |
|
| 576 |
if has_reddit and has_scrapecreators: |
| 577 |
lines.append("│ ✅ Reddit — full threads with comments │") |
| 578 |
elif has_reddit: |
| 579 |
lines.append("│ ✅ Reddit — public threads (titles + scores) │") |
| 580 |
else: |
| 581 |
lines.append("│ ❌ Reddit — unavailable │") |
| 582 |
|
| 583 |
if has_x: |
| 584 |
lines.append("│ ✅ X/Twitter — available │") |
| 585 |
else: |
| 586 |
lines.append("│ ❌ X/Twitter — No X auth or fallback key │") |
| 587 |
if diag.get("bird_installed"): |
| 588 |
lines.append("│ └─ Add AUTH_TOKEN/CT0 or XAI_API_KEY │") |
| 589 |
else: |
| 590 |
lines.append("│ └─ Needs Node.js 22+ (Bird is bundled) │") |
| 591 |
|
| 592 |
if has_youtube: |
| 593 |
lines.append("│ ✅ YouTube — yt-dlp found │") |
| 594 |
else: |
| 595 |
lines.append("│ ❌ YouTube — yt-dlp not installed │") |
| 596 |
lines.append("│ └─ Fix: brew install yt-dlp (free) │") |
| 597 |
|
| 598 |
if has_xiaohongshu: |
| 599 |
lines.append("│ ✅ Xiaohongshu — API connected + logged in │") |
| 600 |
|
| 601 |
if has_web: |
| 602 |
backend = native_web_backend or "native" |
| 603 |
lines.append(f"│ ✅ Web — {backend} API available{' ' * max(0, 13 - len(backend))}│") |
| 604 |
else: |
| 605 |
lines.append("│ ⚡ Web — Add BRAVE_API_KEY or SERPER_API_KEY │") |
| 606 |
|
| 607 |
lines.append("│ │") |
| 608 |
lines.append("│ Config: ~/.config/last30days/.env │") |
| 609 |
lines.append("└─────────────────────────────────────────────────────┘") |
| 610 |
|
| 611 |
sys.stderr.write("\n".join(lines) + "\n\n") |
| 612 |
sys.stderr.flush() |
| 613 |
|
| 614 |
|
| 615 |
def print_phase(phase: str, message: str): |
| 616 |
"""Print a phase message.""" |
| 617 |
colors = { |
| 618 |
"reddit": Colors.YELLOW, |
| 619 |
"x": Colors.CYAN, |
| 620 |
"process": Colors.PURPLE, |
| 621 |
"done": Colors.GREEN, |
| 622 |
"error": Colors.RED, |
| 623 |
} |
| 624 |
color = colors.get(phase, Colors.RESET) |
| 625 |
sys.stderr.write(f"{color}▸{Colors.RESET} {message}\n") |
| 626 |
sys.stderr.flush() |
| 627 |
|