Commit 33fe1d32 authored by MD. SHAHIDUL ISLAM's avatar MD. SHAHIDUL ISLAM

feat: implement custom CLI theme and standardize user interface prompts across tools

parent b90a3485
...@@ -6,12 +6,13 @@ from dotenv import find_dotenv, set_key ...@@ -6,12 +6,13 @@ from dotenv import find_dotenv, set_key
from rich.console import Console from rich.console import Console
from cli.models import AnalystType, AssetType from cli.models import AnalystType, AssetType
from cli.theme import CLI_THEME, PROMPT_MARK, PROMPT_POINTER, PROMPT_STYLE
from dohasecuritiesstockai.default_config import DEFAULT_CONFIG from dohasecuritiesstockai.default_config import DEFAULT_CONFIG
from dohasecuritiesstockai.languages import OUTPUT_LANGUAGE_CHOICES from dohasecuritiesstockai.languages import OUTPUT_LANGUAGE_CHOICES
from dohasecuritiesstockai.llm_clients.api_key_env import get_api_key_env from dohasecuritiesstockai.llm_clients.api_key_env import get_api_key_env
from dohasecuritiesstockai.llm_clients.model_catalog import get_model_options from dohasecuritiesstockai.llm_clients.model_catalog import get_model_options
console = Console() console = Console(theme=CLI_THEME, highlight=False)
TICKER_INPUT_EXAMPLES = "GP, BRACBANK, SQURPHARMA" TICKER_INPUT_EXAMPLES = "GP, BRACBANK, SQURPHARMA"
...@@ -49,16 +50,12 @@ def get_ticker() -> str: ...@@ -49,16 +50,12 @@ def get_ticker() -> str:
is_valid_ticker_input(x) is_valid_ticker_input(x)
or "Please enter a valid ticker symbol, e.g. AAPL, 000404.SZ, 0700.HK, GC=F." or "Please enter a valid ticker symbol, e.g. AAPL, 000404.SZ, 0700.HK, GC=F."
), ),
style=questionary.Style( qmark=PROMPT_MARK,
[ style=PROMPT_STYLE,
("text", "fg:green"),
("highlighted", "noinherit"),
]
),
).ask() ).ask()
if ticker is None: if ticker is None:
console.print("\n[red]No ticker symbol provided. Exiting...[/red]") console.print("\n[danger]No ticker symbol provided. Exiting…[/danger]")
exit(1) exit(1)
return normalize_ticker_symbol(ticker) if ticker.strip() else "GP" return normalize_ticker_symbol(ticker) if ticker.strip() else "GP"
...@@ -114,11 +111,7 @@ def filter_analysts_for_asset_type( ...@@ -114,11 +111,7 @@ def filter_analysts_for_asset_type(
) -> list[AnalystType]: ) -> list[AnalystType]:
if asset_type != AssetType.CRYPTO: if asset_type != AssetType.CRYPTO:
return analysts return analysts
return [ return [analyst for analyst in analysts if analyst != AnalystType.FUNDAMENTALS]
analyst
for analyst in analysts
if analyst != AnalystType.FUNDAMENTALS
]
def get_analysis_date() -> str: def get_analysis_date() -> str:
...@@ -137,18 +130,15 @@ def get_analysis_date() -> str: ...@@ -137,18 +130,15 @@ def get_analysis_date() -> str:
date = questionary.text( date = questionary.text(
"Enter the analysis date (YYYY-MM-DD):", "Enter the analysis date (YYYY-MM-DD):",
validate=lambda x: validate_date(x.strip()) validate=lambda x: (
or "Please enter a valid date in YYYY-MM-DD format.", validate_date(x.strip()) or "Please enter a valid date in YYYY-MM-DD format."
style=questionary.Style(
[
("text", "fg:green"),
("highlighted", "noinherit"),
]
), ),
qmark=PROMPT_MARK,
style=PROMPT_STYLE,
).ask() ).ask()
if not date: if not date:
console.print("\n[red]No date provided. Exiting...[/red]") console.print("\n[danger]No date provided. Exiting…[/danger]")
exit(1) exit(1)
return date.strip() return date.strip()
...@@ -170,26 +160,21 @@ def select_analysts( ...@@ -170,26 +160,21 @@ def select_analysts(
analyst for analyst in available_analysts if analyst != AnalystType.SOCIAL analyst for analyst in available_analysts if analyst != AnalystType.SOCIAL
] ]
choices = questionary.checkbox( choices = questionary.checkbox(
"Select Your [Analysts Team]:", "Choose analyst coverage:",
choices=[ choices=[
questionary.Choice(display, value=value) questionary.Choice(display, value=value)
for display, value in ANALYST_ORDER for display, value in ANALYST_ORDER
if value in available_analysts if value in available_analysts
], ],
instruction="\n- Press Space to select/unselect analysts\n- Press 'a' to select/unselect all\n- Press Enter when done", instruction="Space select · A toggle all · Enter continue",
validate=lambda x: len(x) > 0 or "You must select at least one analyst.", validate=lambda x: len(x) > 0 or "You must select at least one analyst.",
style=questionary.Style( qmark=PROMPT_MARK,
[ pointer=PROMPT_POINTER,
("checkbox-selected", "fg:green"), style=PROMPT_STYLE,
("selected", "fg:green noinherit"),
("highlighted", "noinherit"),
("pointer", "noinherit"),
]
),
).ask() ).ask()
if not choices: if not choices:
console.print("\n[red]No analysts selected. Exiting...[/red]") console.print("\n[danger]No analysts selected. Exiting…[/danger]")
exit(1) exit(1)
return choices return choices
...@@ -200,28 +185,22 @@ def select_research_depth() -> int: ...@@ -200,28 +185,22 @@ def select_research_depth() -> int:
# Define research depth options with their corresponding values # Define research depth options with their corresponding values
DEPTH_OPTIONS = [ DEPTH_OPTIONS = [
("Shallow - Quick research, few debate and strategy discussion rounds", 1), ("Quick scan · 1 debate and risk round", 1),
("Medium - Middle ground, moderate debate rounds and strategy discussion", 3), ("Balanced review · 3 debate and risk rounds", 3),
("Deep - Comprehensive research, in depth debate and strategy discussion", 5), ("High-conviction review · 5 debate and risk rounds", 5),
] ]
choice = questionary.select( choice = questionary.select(
"Select Your [Research Depth]:", "Choose research depth:",
choices=[ choices=[questionary.Choice(display, value=value) for display, value in DEPTH_OPTIONS],
questionary.Choice(display, value=value) for display, value in DEPTH_OPTIONS instruction="Arrow keys move · Enter select",
], qmark=PROMPT_MARK,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select", pointer=PROMPT_POINTER,
style=questionary.Style( style=PROMPT_STYLE,
[
("selected", "fg:yellow noinherit"),
("highlighted", "fg:yellow noinherit"),
("pointer", "fg:yellow noinherit"),
]
),
).ask() ).ask()
if choice is None: if choice is None:
console.print("\n[red]No research depth selected. Exiting...[/red]") console.print("\n[danger]No research depth selected. Exiting…[/danger]")
exit(1) exit(1)
return choice return choice
...@@ -235,14 +214,24 @@ def select_research_depth() -> int: ...@@ -235,14 +214,24 @@ def select_research_depth() -> int:
# shortlist. Provider names are stable (unlike model IDs), so this rarely needs # shortlist. Provider names are stable (unlike model IDs), so this rarely needs
# touching; anything not here is still reachable via Custom ID. # touching; anything not here is still reachable via Custom ID.
_OPENROUTER_MAINSTREAM = { _OPENROUTER_MAINSTREAM = {
"openai", "anthropic", "google", "deepseek", "qwen", "mistralai", "openai",
"meta-llama", "x-ai", "z-ai", "minimax", "moonshotai", "anthropic",
"google",
"deepseek",
"qwen",
"mistralai",
"meta-llama",
"x-ai",
"z-ai",
"minimax",
"moonshotai",
} }
def _fetch_openrouter_models() -> list[tuple[str, str]]: def _fetch_openrouter_models() -> list[tuple[str, str]]:
"""Fetch available models from the OpenRouter API.""" """Fetch available models from the OpenRouter API."""
import requests import requests
try: try:
resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10) resp = requests.get("https://openrouter.ai/api/v1/models", timeout=10)
resp.raise_for_status() resp.raise_for_status()
...@@ -253,7 +242,7 @@ def _fetch_openrouter_models() -> list[tuple[str, str]]: ...@@ -253,7 +242,7 @@ def _fetch_openrouter_models() -> list[tuple[str, str]]:
models.sort(key=lambda m: m.get("created") or 0, reverse=True) models.sort(key=lambda m: m.get("created") or 0, reverse=True)
return [(m.get("name") or m["id"], m["id"]) for m in models] return [(m.get("name") or m["id"], m["id"]) for m in models]
except Exception as e: except Exception as e:
console.print(f"\n[yellow]Could not fetch OpenRouter models: {e}[/yellow]") console.print(f"\n[warning]Could not fetch OpenRouter models: {e}[/warning]")
return [] return []
...@@ -267,9 +256,11 @@ def _require_text(message: str, hint: str) -> str: ...@@ -267,9 +256,11 @@ def _require_text(message: str, hint: str) -> str:
response = questionary.text( response = questionary.text(
message, message,
validate=lambda x: len(x.strip()) > 0 or hint, validate=lambda x: len(x.strip()) > 0 or hint,
qmark=PROMPT_MARK,
style=PROMPT_STYLE,
).ask() ).ask()
if response is None: if response is None:
console.print("\n[red]Cancelled. Exiting...[/red]") console.print("\n[danger]Cancelled. Exiting…[/danger]")
exit(1) exit(1)
return response.strip() return response.strip()
...@@ -284,7 +275,8 @@ def select_openrouter_model(mode: str) -> str: ...@@ -284,7 +275,8 @@ def select_openrouter_model(mode: str) -> str:
# Prefer the newest from mainstream providers so the shortlist isn't crowded # Prefer the newest from mainstream providers so the shortlist isn't crowded
# out by niche/experimental releases; fall back to all if none match. # out by niche/experimental releases; fall back to all if none match.
mainstream = [ mainstream = [
(name, mid) for name, mid in models (name, mid)
for name, mid in models
if not mid.startswith("~") # skip variant/alias duplicate routes if not mid.startswith("~") # skip variant/alias duplicate routes
and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM
] ]
...@@ -296,16 +288,14 @@ def select_openrouter_model(mode: str) -> str: ...@@ -296,16 +288,14 @@ def select_openrouter_model(mode: str) -> str:
choice = questionary.select( choice = questionary.select(
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):", f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):",
choices=choices, choices=choices,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select", instruction="Arrow keys move · Enter select",
style=questionary.Style([ qmark=PROMPT_MARK,
("selected", "fg:magenta noinherit"), pointer=PROMPT_POINTER,
("highlighted", "fg:magenta noinherit"), style=PROMPT_STYLE,
("pointer", "fg:magenta noinherit"),
]),
).ask() ).ask()
if choice is None: if choice is None:
console.print("\n[red]No model selected. Exiting...[/red]") console.print("\n[danger]No model selected. Exiting…[/danger]")
exit(1) exit(1)
if choice == "custom": if choice == "custom":
return _require_text( return _require_text(
...@@ -337,18 +327,14 @@ def _select_model(provider: str, mode: str) -> str: ...@@ -337,18 +327,14 @@ def _select_model(provider: str, mode: str) -> str:
questionary.Choice(display, value=value) questionary.Choice(display, value=value)
for display, value in get_model_options(provider, mode) for display, value in get_model_options(provider, mode)
], ],
instruction="\n- Use arrow keys to navigate\n- Press Enter to select", instruction="Arrow keys move · Enter select",
style=questionary.Style( qmark=PROMPT_MARK,
[ pointer=PROMPT_POINTER,
("selected", "fg:magenta noinherit"), style=PROMPT_STYLE,
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
).ask() ).ask()
if choice is None: if choice is None:
console.print(f"\n[red]No {mode} thinking llm engine selected. Exiting...[/red]") console.print(f"\n[danger]No {mode} thinking model selected. Exiting…[/danger]")
exit(1) exit(1)
if choice == "custom": if choice == "custom":
...@@ -366,6 +352,7 @@ def select_deep_thinking_agent(provider) -> str: ...@@ -366,6 +352,7 @@ def select_deep_thinking_agent(provider) -> str:
"""Select deep thinking llm engine using an interactive selection.""" """Select deep thinking llm engine using an interactive selection."""
return _select_model(provider, "deep") return _select_model(provider, "deep")
def _llm_provider_table() -> list[tuple[str, str, str | None]]: def _llm_provider_table() -> list[tuple[str, str, str | None]]:
"""(display_name, provider_key, base_url) for every supported provider. """(display_name, provider_key, base_url) for every supported provider.
...@@ -424,11 +411,15 @@ def prompt_openai_compatible_url() -> str: ...@@ -424,11 +411,15 @@ def prompt_openai_compatible_url() -> str:
url = questionary.text( url = questionary.text(
"Enter the OpenAI-compatible base URL " "Enter the OpenAI-compatible base URL "
"(e.g. http://localhost:8000/v1 for vLLM, http://localhost:1234/v1 for LM Studio):", "(e.g. http://localhost:8000/v1 for vLLM, http://localhost:1234/v1 for LM Studio):",
validate=lambda x: x.strip().startswith(("http://", "https://")) validate=lambda x: (
or "Enter a URL starting with http:// or https://", x.strip().startswith(("http://", "https://"))
or "Enter a URL starting with http:// or https://"
),
qmark=PROMPT_MARK,
style=PROMPT_STYLE,
).ask() ).ask()
if not url: if not url:
console.print("\n[red]No endpoint URL provided. Exiting...[/red]") console.print("\n[danger]No endpoint URL provided. Exiting…[/danger]")
exit(1) exit(1)
return url.strip() return url.strip()
...@@ -443,18 +434,14 @@ def select_llm_provider() -> tuple[str, str | None]: ...@@ -443,18 +434,14 @@ def select_llm_provider() -> tuple[str, str | None]:
questionary.Choice(display, value=(provider_key, url)) questionary.Choice(display, value=(provider_key, url))
for display, provider_key, url in PROVIDERS for display, provider_key, url in PROVIDERS
], ],
instruction="\n- Use arrow keys to navigate\n- Press Enter to select", instruction="Arrow keys move · Enter select",
style=questionary.Style( qmark=PROMPT_MARK,
[ pointer=PROMPT_POINTER,
("selected", "fg:magenta noinherit"), style=PROMPT_STYLE,
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
).ask() ).ask()
if choice is None: if choice is None:
console.print("\n[red]No LLM provider selected. Exiting...[/red]") console.print("\n[danger]No LLM provider selected. Exiting…[/danger]")
exit(1) exit(1)
provider, url = choice provider, url = choice
...@@ -471,11 +458,9 @@ def ask_openai_reasoning_effort() -> str: ...@@ -471,11 +458,9 @@ def ask_openai_reasoning_effort() -> str:
return questionary.select( return questionary.select(
"Select Reasoning Effort:", "Select Reasoning Effort:",
choices=choices, choices=choices,
style=questionary.Style([ qmark=PROMPT_MARK,
("selected", "fg:cyan noinherit"), pointer=PROMPT_POINTER,
("highlighted", "fg:cyan noinherit"), style=PROMPT_STYLE,
("pointer", "fg:cyan noinherit"),
]),
).ask() ).ask()
...@@ -493,11 +478,9 @@ def ask_anthropic_effort() -> str | None: ...@@ -493,11 +478,9 @@ def ask_anthropic_effort() -> str | None:
questionary.Choice("Medium (balanced)", "medium"), questionary.Choice("Medium (balanced)", "medium"),
questionary.Choice("Low (faster, cheaper)", "low"), questionary.Choice("Low (faster, cheaper)", "low"),
], ],
style=questionary.Style([ qmark=PROMPT_MARK,
("selected", "fg:cyan noinherit"), pointer=PROMPT_POINTER,
("highlighted", "fg:cyan noinherit"), style=PROMPT_STYLE,
("pointer", "fg:cyan noinherit"),
]),
).ask() ).ask()
...@@ -513,11 +496,9 @@ def ask_gemini_thinking_config() -> str | None: ...@@ -513,11 +496,9 @@ def ask_gemini_thinking_config() -> str | None:
questionary.Choice("Enable Thinking (recommended)", "high"), questionary.Choice("Enable Thinking (recommended)", "high"),
questionary.Choice("Minimal/Disable Thinking", "minimal"), questionary.Choice("Minimal/Disable Thinking", "minimal"),
], ],
style=questionary.Style([ qmark=PROMPT_MARK,
("selected", "fg:green noinherit"), pointer=PROMPT_POINTER,
("highlighted", "fg:green noinherit"), style=PROMPT_STYLE,
("pointer", "fg:green noinherit"),
]),
).ask() ).ask()
...@@ -539,11 +520,9 @@ def ask_glm_region() -> tuple[str, str]: ...@@ -539,11 +520,9 @@ def ask_glm_region() -> tuple[str, str]:
value=("glm-cn", "https://open.bigmodel.cn/api/paas/v4/"), value=("glm-cn", "https://open.bigmodel.cn/api/paas/v4/"),
), ),
], ],
style=questionary.Style([ qmark=PROMPT_MARK,
("selected", "fg:cyan noinherit"), pointer=PROMPT_POINTER,
("highlighted", "fg:cyan noinherit"), style=PROMPT_STYLE,
("pointer", "fg:cyan noinherit"),
]),
).ask() ).ask()
...@@ -566,11 +545,9 @@ def ask_qwen_region() -> tuple[str, str]: ...@@ -566,11 +545,9 @@ def ask_qwen_region() -> tuple[str, str]:
value=("qwen-cn", "https://dashscope.aliyuncs.com/compatible-mode/v1"), value=("qwen-cn", "https://dashscope.aliyuncs.com/compatible-mode/v1"),
), ),
], ],
style=questionary.Style([ qmark=PROMPT_MARK,
("selected", "fg:cyan noinherit"), pointer=PROMPT_POINTER,
("highlighted", "fg:cyan noinherit"), style=PROMPT_STYLE,
("pointer", "fg:cyan noinherit"),
]),
).ask() ).ask()
...@@ -593,11 +570,9 @@ def ask_minimax_region() -> tuple[str, str]: ...@@ -593,11 +570,9 @@ def ask_minimax_region() -> tuple[str, str]:
value=("minimax-cn", "https://api.minimaxi.com/v1"), value=("minimax-cn", "https://api.minimaxi.com/v1"),
), ),
], ],
style=questionary.Style([ qmark=PROMPT_MARK,
("selected", "fg:cyan noinherit"), pointer=PROMPT_POINTER,
("highlighted", "fg:cyan noinherit"), style=PROMPT_STYLE,
("pointer", "fg:cyan noinherit"),
]),
).ask() ).ask()
...@@ -613,21 +588,21 @@ def confirm_ollama_endpoint(url: str) -> None: ...@@ -613,21 +588,21 @@ def confirm_ollama_endpoint(url: str) -> None:
""" """
from_env = os.environ.get("OLLAMA_BASE_URL") from_env = os.environ.get("OLLAMA_BASE_URL")
origin = " (from OLLAMA_BASE_URL)" if from_env and from_env == url else "" origin = " (from OLLAMA_BASE_URL)" if from_env and from_env == url else ""
console.print(f"[green]✓ Using Ollama at {url}{origin}[/green]") console.print(f"[success]✓ Using Ollama at {url}{origin}[/success]")
if not url.startswith(("http://", "https://")): if not url.startswith(("http://", "https://")):
console.print( console.print(
f"[yellow]Note: {url!r} is missing a scheme. " f"[warning]Note: {url!r} is missing a scheme. "
f"Ollama-serve typically expects a URL like " f"Ollama-serve typically expects a URL like "
f"http://<host>:11434/v1.[/yellow]" f"http://<host>:11434/v1.[/warning]"
) )
elif ":11434" not in url and "://localhost" not in url and "://127.0.0.1" not in url: elif ":11434" not in url and "://localhost" not in url and "://127.0.0.1" not in url:
# Soft hint when the port differs from the ollama-serve default # Soft hint when the port differs from the ollama-serve default
# and the host isn't local (where users sometimes proxy on :80). # and the host isn't local (where users sometimes proxy on :80).
console.print( console.print(
f"[yellow]Note: {url!r} doesn't include port 11434. " f"[warning]Note: {url!r} doesn't include port 11434. "
f"Make sure your remote ollama-serve listens on the port " f"Make sure your remote ollama-serve listens on the port "
f"shown above.[/yellow]" f"shown above.[/warning]"
) )
...@@ -649,6 +624,7 @@ def ensure_api_key(provider: str) -> str | None: ...@@ -649,6 +624,7 @@ def ensure_api_key(provider: str) -> str | None:
# Key-optional providers (generic OpenAI-compatible / local servers) read the # Key-optional providers (generic OpenAI-compatible / local servers) read the
# key when present but must never force an interactive prompt. # key when present but must never force an interactive prompt.
from dohasecuritiesstockai.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS from dohasecuritiesstockai.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower()) spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
if spec is not None and spec.key_optional: if spec is not None and spec.key_optional:
return os.environ.get(env_var) return os.environ.get(env_var)
...@@ -657,27 +633,21 @@ def ensure_api_key(provider: str) -> str | None: ...@@ -657,27 +633,21 @@ def ensure_api_key(provider: str) -> str | None:
if existing: if existing:
return existing return existing
console.print( console.print(f"\n[warning]{env_var} is not set in your environment.[/warning]")
f"\n[yellow]{env_var} is not set in your environment.[/yellow]"
)
key = questionary.password( key = questionary.password(
f"Paste your {env_var} (will be saved to .env):", f"Paste your {env_var} (will be saved to .env):",
style=questionary.Style([ qmark=PROMPT_MARK,
("text", "fg:cyan"), style=PROMPT_STYLE,
("highlighted", "noinherit"),
]),
).ask() ).ask()
if not key: if not key:
console.print( console.print(f"[danger]Skipped. API calls will fail until {env_var} is set.[/danger]")
f"[red]Skipped. API calls will fail until {env_var} is set.[/red]"
)
return None return None
env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env") env_path = find_dotenv(usecwd=True) or str(Path.cwd() / ".env")
Path(env_path).touch(exist_ok=True) Path(env_path).touch(exist_ok=True)
set_key(env_path, env_var, key) set_key(env_path, env_var, key)
os.environ[env_var] = key os.environ[env_var] = key
console.print(f"[green]Saved {env_var} to {env_path}[/green]") console.print(f"[success]Saved {env_var} to {env_path}[/success]")
return key return key
...@@ -685,15 +655,10 @@ def ask_output_language() -> str: ...@@ -685,15 +655,10 @@ def ask_output_language() -> str:
"""Ask for the English or Bangla report-output language.""" """Ask for the English or Bangla report-output language."""
choice = questionary.select( choice = questionary.select(
"Select Output Language:", "Select Output Language:",
choices=[ choices=[questionary.Choice(label, value) for label, value in OUTPUT_LANGUAGE_CHOICES],
questionary.Choice(label, value) qmark=PROMPT_MARK,
for label, value in OUTPUT_LANGUAGE_CHOICES pointer=PROMPT_POINTER,
], style=PROMPT_STYLE,
style=questionary.Style([
("selected", "fg:yellow noinherit"),
("highlighted", "fg:yellow noinherit"),
("pointer", "fg:yellow noinherit"),
]),
).ask() ).ask()
# Output language has a sensible default, so a cancel falls back to English # Output language has a sensible default, so a cancel falls back to English
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment