@drico2008/fincli 0.1.0

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.
Files changed (69) hide show
  1. package/README.md +644 -0
  2. package/fincli/__init__.py +3 -0
  3. package/fincli/app/__init__.py +1 -0
  4. package/fincli/app/analysis/__init__.py +1 -0
  5. package/fincli/app/analysis/ai_prompts.py +33 -0
  6. package/fincli/app/analysis/analyzer.py +119 -0
  7. package/fincli/app/analysis/assistant_context.py +161 -0
  8. package/fincli/app/analysis/indicators.py +143 -0
  9. package/fincli/app/analysis/market_structure.py +106 -0
  10. package/fincli/app/analysis/technical_debate.py +251 -0
  11. package/fincli/app/analysis/technical_signal.py +203 -0
  12. package/fincli/app/cli/__init__.py +1 -0
  13. package/fincli/app/cli/autocomplete.py +17 -0
  14. package/fincli/app/cli/commands.py +82 -0
  15. package/fincli/app/cli/router.py +1257 -0
  16. package/fincli/app/main.py +16 -0
  17. package/fincli/app/modules/__init__.py +1 -0
  18. package/fincli/app/modules/economic_calendar.py +139 -0
  19. package/fincli/app/modules/exporter.py +51 -0
  20. package/fincli/app/modules/journal.py +65 -0
  21. package/fincli/app/modules/journal_analytics.py +70 -0
  22. package/fincli/app/modules/portfolio.py +34 -0
  23. package/fincli/app/modules/scanner.py +105 -0
  24. package/fincli/app/modules/transactions.py +84 -0
  25. package/fincli/app/modules/watchlist.py +25 -0
  26. package/fincli/app/providers/__init__.py +1 -0
  27. package/fincli/app/providers/ai/__init__.py +1 -0
  28. package/fincli/app/providers/ai/anthropic_provider.py +11 -0
  29. package/fincli/app/providers/ai/base.py +29 -0
  30. package/fincli/app/providers/ai/gemini_provider.py +11 -0
  31. package/fincli/app/providers/ai/groq_provider.py +11 -0
  32. package/fincli/app/providers/ai/http_provider.py +145 -0
  33. package/fincli/app/providers/ai/huggingface_provider.py +11 -0
  34. package/fincli/app/providers/ai/manager.py +60 -0
  35. package/fincli/app/providers/ai/openai_provider.py +11 -0
  36. package/fincli/app/providers/ai/openrouter_provider.py +11 -0
  37. package/fincli/app/providers/ai/together_provider.py +11 -0
  38. package/fincli/app/providers/market/__init__.py +1 -0
  39. package/fincli/app/providers/market/base.py +77 -0
  40. package/fincli/app/providers/market/custom_provider.py +169 -0
  41. package/fincli/app/providers/market/finnhub_provider.py +187 -0
  42. package/fincli/app/providers/market/manager.py +123 -0
  43. package/fincli/app/providers/market/news_provider.py +28 -0
  44. package/fincli/app/providers/market/symbols.py +182 -0
  45. package/fincli/app/providers/market/twelvedata_provider.py +167 -0
  46. package/fincli/app/providers/market/yfinance_provider.py +447 -0
  47. package/fincli/app/services/__init__.py +1 -0
  48. package/fincli/app/services/market_data.py +203 -0
  49. package/fincli/app/services/market_overview.py +111 -0
  50. package/fincli/app/storage/__init__.py +1 -0
  51. package/fincli/app/storage/cache.py +38 -0
  52. package/fincli/app/storage/config.py +114 -0
  53. package/fincli/app/storage/database.py +101 -0
  54. package/fincli/app/storage/market_cache.py +92 -0
  55. package/fincli/app/tui/__init__.py +1 -0
  56. package/fincli/app/tui/components.py +55 -0
  57. package/fincli/app/tui/layout.py +261 -0
  58. package/fincli/app/tui/market_provider_selector.py +267 -0
  59. package/fincli/app/tui/model_selector.py +412 -0
  60. package/fincli/app/tui/theme.py +157 -0
  61. package/fincli/app/utils/__init__.py +1 -0
  62. package/fincli/app/utils/errors.py +33 -0
  63. package/fincli/app/utils/formatting.py +17 -0
  64. package/fincli/app/utils/logger.py +19 -0
  65. package/npm/bin/fincli.js +35 -0
  66. package/npm/postinstall.js +72 -0
  67. package/package.json +23 -0
  68. package/pyproject.toml +31 -0
  69. package/requirements.txt +9 -0
@@ -0,0 +1,111 @@
1
+ """Market overview orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from fincli.app.analysis.indicators import TechnicalSummary, summarize_technical_indicators
8
+ from fincli.app.analysis.market_structure import MarketStructureSummary, analyze_market_structure
9
+ from fincli.app.providers.market.base import Candle, FundamentalSnapshot, NewsItem, Quote
10
+ from fincli.app.services.market_data import MarketDataService
11
+ from fincli.app.utils.errors import FinCLIError
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class DataQuality:
16
+ score: int
17
+ quote: str
18
+ ohlcv: str
19
+ news: str
20
+ fundamentals: str
21
+ provider: str
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class MarketOverview:
26
+ symbol: str
27
+ timeframe: str
28
+ quote: Quote
29
+ candles: list[Candle]
30
+ technical: TechnicalSummary
31
+ structure: MarketStructureSummary
32
+ news: list[NewsItem]
33
+ fundamentals: FundamentalSnapshot | None
34
+ data_quality: DataQuality
35
+
36
+
37
+ async def build_market_overview(symbol: str, market_service: MarketDataService, timeframe: str = "1d") -> MarketOverview:
38
+ """Build a compact market overview from available provider data."""
39
+ normalized = symbol.upper()
40
+ quote = await market_service.quote(normalized)
41
+ candles = await market_service.history(normalized, period="6mo", interval=timeframe)
42
+ technical = summarize_technical_indicators(candles)
43
+ structure = analyze_market_structure(candles)
44
+
45
+ try:
46
+ news = await market_service.news(normalized, limit=3)
47
+ except FinCLIError:
48
+ news = []
49
+
50
+ try:
51
+ fundamentals = await market_service.fundamentals(normalized)
52
+ except FinCLIError:
53
+ fundamentals = None
54
+
55
+ quality = _score_data_quality(quote, candles, news, fundamentals)
56
+ return MarketOverview(
57
+ symbol=normalized,
58
+ timeframe=timeframe,
59
+ quote=quote,
60
+ candles=candles,
61
+ technical=technical,
62
+ structure=structure,
63
+ news=news,
64
+ fundamentals=fundamentals,
65
+ data_quality=quality,
66
+ )
67
+
68
+
69
+ def _score_data_quality(
70
+ quote: Quote,
71
+ candles: list[Candle],
72
+ news: list[NewsItem],
73
+ fundamentals: FundamentalSnapshot | None,
74
+ ) -> DataQuality:
75
+ score = 0
76
+ quote_status = "ok" if quote.price is not None else "missing"
77
+ if quote.price is not None:
78
+ score += 25
79
+
80
+ candle_count = len(candles)
81
+ if candle_count >= 120:
82
+ ohlcv_status = f"strong ({candle_count} candles)"
83
+ score += 35
84
+ elif candle_count >= 20:
85
+ ohlcv_status = f"usable ({candle_count} candles)"
86
+ score += 25
87
+ elif candle_count:
88
+ ohlcv_status = f"weak ({candle_count} candles)"
89
+ score += 10
90
+ else:
91
+ ohlcv_status = "missing"
92
+
93
+ news_status = f"{len(news)} item(s)" if news else "missing"
94
+ if news:
95
+ score += 15
96
+
97
+ fundamentals_status = "ok" if fundamentals is not None else "missing"
98
+ if fundamentals is not None:
99
+ score += 20
100
+
101
+ if quote.status == "realtime":
102
+ score += 5
103
+
104
+ return DataQuality(
105
+ score=min(score, 100),
106
+ quote=quote_status,
107
+ ohlcv=ohlcv_status,
108
+ news=news_status,
109
+ fundamentals=fundamentals_status,
110
+ provider=f"{quote.provider} ({quote.status})",
111
+ )
@@ -0,0 +1 @@
1
+ """Storage modules."""
@@ -0,0 +1,38 @@
1
+ """Lightweight in-memory cache scaffold for provider responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from time import monotonic
7
+ from typing import Generic, TypeVar
8
+
9
+ T = TypeVar("T")
10
+
11
+
12
+ @dataclass(slots=True)
13
+ class CacheEntry(Generic[T]):
14
+ value: T
15
+ expires_at: float
16
+
17
+
18
+ class TTLCache(Generic[T]):
19
+ """Simple TTL cache for Phase 1. Provider modules can replace this later."""
20
+
21
+ def __init__(self, default_ttl: int = 300) -> None:
22
+ self.default_ttl = default_ttl
23
+ self._items: dict[str, CacheEntry[T]] = {}
24
+
25
+ def get(self, key: str) -> T | None:
26
+ entry = self._items.get(key)
27
+ if entry is None:
28
+ return None
29
+ if entry.expires_at < monotonic():
30
+ self._items.pop(key, None)
31
+ return None
32
+ return entry.value
33
+
34
+ def set(self, key: str, value: T, ttl: int | None = None) -> None:
35
+ self._items[key] = CacheEntry(value=value, expires_at=monotonic() + (ttl or self.default_ttl))
36
+
37
+ def clear(self) -> None:
38
+ self._items.clear()
@@ -0,0 +1,114 @@
1
+ """Configuration loading and persistence.
2
+
3
+ Secrets are read from environment variables. Non-secret preferences are stored
4
+ in a local JSON config file under ~/.fincli by default.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ try:
16
+ from dotenv import load_dotenv
17
+ except ImportError: # pragma: no cover - dependency exists in normal install
18
+ load_dotenv = None # type: ignore[assignment]
19
+
20
+ from fincli.app.utils.errors import ConfigError
21
+ from fincli.app.utils.formatting import mask_secret
22
+
23
+
24
+ APP_DIR = Path.home() / ".fincli"
25
+ CONFIG_FILE = APP_DIR / "config.json"
26
+
27
+
28
+ @dataclass(slots=True)
29
+ class FinCLISettings:
30
+ ai_provider: str = "openrouter"
31
+ ai_model: str = "openai/gpt-4o-mini"
32
+ market_provider: str = "yfinance"
33
+ news_provider: str = "yfinance"
34
+ market_provider_priority: list[str] = field(default_factory=lambda: ["yfinance"])
35
+ timezone: str = "Asia/Jakarta"
36
+ default_currency: str = "USD"
37
+ cache_ttl_seconds: int = 300
38
+ theme: str = "fincli-dark"
39
+
40
+ def safe_dict(self) -> dict[str, Any]:
41
+ """Return display-safe config, including masked secret status."""
42
+ data = asdict(self)
43
+ data["api_keys"] = {
44
+ "openrouter": mask_secret(os.getenv("OPENROUTER_API_KEY")),
45
+ "gemini": mask_secret(os.getenv("GEMINI_API_KEY")),
46
+ "anthropic": mask_secret(os.getenv("ANTHROPIC_API_KEY")),
47
+ "openai": mask_secret(os.getenv("OPENAI_API_KEY")),
48
+ "together": mask_secret(os.getenv("TOGETHER_API_KEY")),
49
+ "huggingface": mask_secret(os.getenv("HUGGINGFACE_API_KEY")),
50
+ "groq": mask_secret(os.getenv("GROQ_API_KEY")),
51
+ "market_data": mask_secret(os.getenv("MARKET_DATA_API_KEY")),
52
+ "news_data": mask_secret(os.getenv("NEWS_DATA_API_KEY")),
53
+ "finnhub": mask_secret(os.getenv("FINNHUB_API_KEY")),
54
+ "twelvedata": mask_secret(os.getenv("TWELVE_DATA_API_KEY")),
55
+ }
56
+ return data
57
+
58
+
59
+ class ConfigManager:
60
+ """Load, update, and persist non-secret FinCLI settings."""
61
+
62
+ def __init__(self, config_file: Path = CONFIG_FILE) -> None:
63
+ self.config_file = config_file
64
+ self.settings = self.load()
65
+
66
+ def load(self) -> FinCLISettings:
67
+ if load_dotenv is not None:
68
+ load_dotenv()
69
+
70
+ if not self.config_file.exists():
71
+ return FinCLISettings()
72
+
73
+ try:
74
+ raw = json.loads(self.config_file.read_text(encoding="utf-8"))
75
+ allowed = FinCLISettings.__dataclass_fields__.keys()
76
+ filtered = {key: value for key, value in raw.items() if key in allowed}
77
+ return FinCLISettings(**filtered)
78
+ except Exception as exc: # noqa: BLE001
79
+ raise ConfigError(
80
+ "Config lokal gagal dibaca.",
81
+ "Periksa ~/.fincli/config.json atau hapus file tersebut untuk memakai default.",
82
+ ) from exc
83
+
84
+ def save(self) -> None:
85
+ try:
86
+ self.config_file.parent.mkdir(parents=True, exist_ok=True)
87
+ self.config_file.write_text(
88
+ json.dumps(asdict(self.settings), indent=2),
89
+ encoding="utf-8",
90
+ )
91
+ except Exception as exc: # noqa: BLE001
92
+ raise ConfigError("Config lokal gagal disimpan.") from exc
93
+
94
+ def set_ai_model(self, provider: str, model: str) -> None:
95
+ self.settings.ai_provider = provider.strip().lower()
96
+ self.settings.ai_model = model.strip()
97
+ self.save()
98
+
99
+ def set_market_provider(self, provider: str) -> None:
100
+ self.settings.market_provider = provider.strip().lower()
101
+ self.save()
102
+
103
+ def set_news_provider(self, provider: str) -> None:
104
+ self.settings.news_provider = provider.strip().lower()
105
+ self.save()
106
+
107
+ def set_market_provider_priority(self, providers: list[str]) -> None:
108
+ normalized = [provider.strip().lower() for provider in providers if provider.strip()]
109
+ if not normalized:
110
+ normalized = ["yfinance"]
111
+ self.settings.market_provider_priority = normalized
112
+ self.settings.market_provider = normalized[0]
113
+ self.settings.news_provider = normalized[0]
114
+ self.save()
@@ -0,0 +1,101 @@
1
+ """SQLite storage for FinCLI local data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from contextlib import closing
7
+ import sqlite3
8
+ from typing import Iterable
9
+
10
+ from fincli.app.storage.config import APP_DIR
11
+ from fincli.app.utils.errors import StorageError
12
+
13
+
14
+ DB_FILE = APP_DIR / "fincli.db"
15
+
16
+
17
+ class FinCLIDatabase:
18
+ """Small SQLite wrapper for watchlist, portfolio, and journal data."""
19
+
20
+ def __init__(self, db_file: Path = DB_FILE) -> None:
21
+ self.db_file = db_file
22
+ self.db_file.parent.mkdir(parents=True, exist_ok=True)
23
+ self.initialize()
24
+
25
+ def connect(self) -> sqlite3.Connection:
26
+ connection = sqlite3.connect(self.db_file)
27
+ connection.row_factory = sqlite3.Row
28
+ return connection
29
+
30
+ def initialize(self) -> None:
31
+ try:
32
+ with closing(self.connect()) as db:
33
+ with db:
34
+ db.executescript(
35
+ """
36
+ CREATE TABLE IF NOT EXISTS watchlist (
37
+ symbol TEXT PRIMARY KEY,
38
+ group_name TEXT DEFAULT 'default',
39
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS portfolio_positions (
43
+ symbol TEXT PRIMARY KEY,
44
+ quantity REAL NOT NULL,
45
+ average_price REAL NOT NULL,
46
+ currency TEXT DEFAULT 'USD',
47
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
48
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS journal_entries (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ instrument TEXT NOT NULL,
54
+ bias TEXT DEFAULT '',
55
+ entry_reason TEXT DEFAULT '',
56
+ exit_reason TEXT DEFAULT '',
57
+ result TEXT DEFAULT '',
58
+ emotion TEXT DEFAULT '',
59
+ lesson TEXT DEFAULT '',
60
+ tags TEXT DEFAULT '',
61
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS portfolio_transactions (
65
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66
+ action TEXT NOT NULL,
67
+ symbol TEXT NOT NULL,
68
+ quantity REAL NOT NULL,
69
+ price REAL NOT NULL,
70
+ currency TEXT DEFAULT 'USD',
71
+ realized_pnl REAL DEFAULT 0,
72
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
73
+ );
74
+
75
+ CREATE TABLE IF NOT EXISTS market_cache (
76
+ namespace TEXT NOT NULL,
77
+ cache_key TEXT NOT NULL,
78
+ payload TEXT NOT NULL,
79
+ expires_at REAL NOT NULL,
80
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
81
+ PRIMARY KEY (namespace, cache_key)
82
+ );
83
+ """
84
+ )
85
+ except sqlite3.Error as exc:
86
+ raise StorageError("Database lokal gagal diinisialisasi.") from exc
87
+
88
+ def execute(self, sql: str, params: Iterable[object] = ()) -> None:
89
+ try:
90
+ with closing(self.connect()) as db:
91
+ with db:
92
+ db.execute(sql, tuple(params))
93
+ except sqlite3.Error as exc:
94
+ raise StorageError("Operasi database gagal.") from exc
95
+
96
+ def query(self, sql: str, params: Iterable[object] = ()) -> list[sqlite3.Row]:
97
+ try:
98
+ with closing(self.connect()) as db:
99
+ return list(db.execute(sql, tuple(params)).fetchall())
100
+ except sqlite3.Error as exc:
101
+ raise StorageError("Query database gagal.") from exc
@@ -0,0 +1,92 @@
1
+ """Persistent SQLite cache for market provider responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from time import time
7
+ from typing import Any
8
+
9
+ from fincli.app.storage.database import FinCLIDatabase
10
+ from fincli.app.utils.errors import StorageError
11
+
12
+
13
+ class MarketCache:
14
+ """Provider response cache with TTL persisted in SQLite."""
15
+
16
+ def __init__(self, db: FinCLIDatabase) -> None:
17
+ self.db = db
18
+
19
+ def get(self, namespace: str, cache_key: str) -> dict[str, Any] | list[Any] | None:
20
+ rows = self.db.query(
21
+ "SELECT payload, expires_at FROM market_cache WHERE namespace = ? AND cache_key = ?",
22
+ (namespace, cache_key),
23
+ )
24
+ if not rows:
25
+ return None
26
+
27
+ row = rows[0]
28
+ if float(row["expires_at"]) <= time():
29
+ self.delete(namespace, cache_key)
30
+ return None
31
+
32
+ try:
33
+ payload = json.loads(str(row["payload"]))
34
+ except json.JSONDecodeError as exc:
35
+ self.delete(namespace, cache_key)
36
+ raise StorageError("Payload cache market rusak dan sudah dihapus.") from exc
37
+
38
+ if isinstance(payload, (dict, list)):
39
+ return payload
40
+ return None
41
+
42
+ def set(self, namespace: str, cache_key: str, payload: dict[str, Any] | list[Any], ttl_seconds: int) -> None:
43
+ encoded = json.dumps(payload, separators=(",", ":"))
44
+ expires_at = time() + max(1, ttl_seconds)
45
+ self.db.execute(
46
+ """
47
+ INSERT INTO market_cache (namespace, cache_key, payload, expires_at)
48
+ VALUES (?, ?, ?, ?)
49
+ ON CONFLICT(namespace, cache_key)
50
+ DO UPDATE SET payload = excluded.payload, expires_at = excluded.expires_at, created_at = CURRENT_TIMESTAMP
51
+ """,
52
+ (namespace, cache_key, encoded, expires_at),
53
+ )
54
+
55
+ def delete(self, namespace: str, cache_key: str) -> None:
56
+ self.db.execute(
57
+ "DELETE FROM market_cache WHERE namespace = ? AND cache_key = ?",
58
+ (namespace, cache_key),
59
+ )
60
+
61
+ def clear(self, namespace: str | None = None) -> int:
62
+ if namespace:
63
+ count = int(self.db.query("SELECT COUNT(*) AS total FROM market_cache WHERE namespace = ?", (namespace,))[0]["total"])
64
+ self.db.execute("DELETE FROM market_cache WHERE namespace = ?", (namespace,))
65
+ return count
66
+ count = int(self.db.query("SELECT COUNT(*) AS total FROM market_cache")[0]["total"])
67
+ self.db.execute("DELETE FROM market_cache")
68
+ return count
69
+
70
+ def prune_expired(self) -> int:
71
+ count = int(self.db.query("SELECT COUNT(*) AS total FROM market_cache WHERE expires_at <= ?", (time(),))[0]["total"])
72
+ self.db.execute("DELETE FROM market_cache WHERE expires_at <= ?", (time(),))
73
+ return count
74
+
75
+ def stats(self) -> dict[str, int]:
76
+ self.prune_expired()
77
+ rows = self.db.query(
78
+ """
79
+ SELECT namespace, COUNT(*) AS total
80
+ FROM market_cache
81
+ GROUP BY namespace
82
+ ORDER BY namespace
83
+ """
84
+ )
85
+ by_namespace = {str(row["namespace"]): int(row["total"]) for row in rows}
86
+ return {
87
+ "total": sum(by_namespace.values()),
88
+ "quote": by_namespace.get("quote", 0),
89
+ "history": by_namespace.get("history", 0),
90
+ "news": by_namespace.get("news", 0),
91
+ "fundamentals": by_namespace.get("fundamentals", 0),
92
+ }
@@ -0,0 +1 @@
1
+ """Textual TUI modules."""
@@ -0,0 +1,55 @@
1
+ """Reusable TUI components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.markdown import Markdown
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+ from textual.widgets import Static
10
+
11
+ from fincli.app.cli.commands import CommandSpec
12
+
13
+
14
+ class CommandPalette(Static):
15
+ """Slash command palette shown near the command input."""
16
+
17
+ def render_commands(self, commands: list[CommandSpec], query: str = "") -> None:
18
+ table = Table.grid(expand=True)
19
+ table.add_column("Command", style="white", no_wrap=True, ratio=1)
20
+ table.add_column("Description", style="bright_black", justify="right", ratio=3)
21
+
22
+ for index, command in enumerate(commands):
23
+ command_text = command.name
24
+ description = command.description
25
+ if index == 0:
26
+ command_text = f"[black on cyan]> {command.name}[/]"
27
+ description = f"[black on cyan]{command.description}[/]"
28
+ table.add_row(command_text, description)
29
+
30
+ if len(commands) > 6:
31
+ table.add_row("[bright_black]v more[/]", "[bright_black]Ketik command lebih spesifik[/]")
32
+
33
+ title = f"[cyan]>[/] {query or '/'}"
34
+ self.update(Panel(table, title=title, border_style="bright_black", padding=(0, 1)))
35
+
36
+ def clear_palette(self) -> None:
37
+ self.update("")
38
+
39
+
40
+ def format_user_message(message: str) -> Panel:
41
+ text = Text()
42
+ text.append("> ", style="bold cyan")
43
+ text.append(message, style="bold white")
44
+ return Panel(text, border_style="#2f332f", style="on #2b2f2b", padding=(0, 1))
45
+
46
+
47
+ def format_thinking_message(message: str) -> Text:
48
+ text = Text()
49
+ text.append("> Thinking: ", style="dim")
50
+ text.append(message, style="italic dim")
51
+ return text
52
+
53
+
54
+ def format_ai_message(message: str) -> Markdown:
55
+ return Markdown(f"* {message}")