bone-agent 1.3.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 (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/npm-wrapper.js +235 -0
  4. package/bin/rg +0 -0
  5. package/bin/rg.exe +0 -0
  6. package/config.yaml.example +133 -0
  7. package/package.json +53 -0
  8. package/requirements.txt +9 -0
  9. package/src/__init__.py +11 -0
  10. package/src/core/__init__.py +1 -0
  11. package/src/core/agentic.py +1054 -0
  12. package/src/core/chat_manager.py +1552 -0
  13. package/src/core/config_manager.py +247 -0
  14. package/src/core/cron.py +527 -0
  15. package/src/core/cron_allowlist.py +118 -0
  16. package/src/core/memory.py +232 -0
  17. package/src/core/retry.py +71 -0
  18. package/src/core/sub_agent.py +326 -0
  19. package/src/core/tool_approval.py +220 -0
  20. package/src/core/tool_feedback.py +778 -0
  21. package/src/exceptions.py +79 -0
  22. package/src/llm/__init__.py +1 -0
  23. package/src/llm/client.py +171 -0
  24. package/src/llm/config.py +466 -0
  25. package/src/llm/prompts.py +735 -0
  26. package/src/llm/providers.py +417 -0
  27. package/src/llm/streaming.py +163 -0
  28. package/src/llm/token_tracker.py +368 -0
  29. package/src/tools/__init__.py +212 -0
  30. package/src/tools/constants.py +59 -0
  31. package/src/tools/create_file.py +136 -0
  32. package/src/tools/directory.py +389 -0
  33. package/src/tools/edit.py +543 -0
  34. package/src/tools/file_reader.py +322 -0
  35. package/src/tools/helpers/__init__.py +105 -0
  36. package/src/tools/helpers/base.py +550 -0
  37. package/src/tools/helpers/converters.py +44 -0
  38. package/src/tools/helpers/file_helpers.py +189 -0
  39. package/src/tools/helpers/formatters.py +411 -0
  40. package/src/tools/helpers/loader.py +231 -0
  41. package/src/tools/helpers/parallel_executor.py +231 -0
  42. package/src/tools/helpers/path_resolver.py +226 -0
  43. package/src/tools/helpers/plugin_manifest.py +156 -0
  44. package/src/tools/obsidian.py +96 -0
  45. package/src/tools/review_sub_agent.py +189 -0
  46. package/src/tools/rg_search.py +393 -0
  47. package/src/tools/search_plugins.py +109 -0
  48. package/src/tools/select_option.py +593 -0
  49. package/src/tools/shell.py +302 -0
  50. package/src/tools/sub_agent.py +139 -0
  51. package/src/tools/task_list.py +269 -0
  52. package/src/tools/web_search.py +61 -0
  53. package/src/ui/__init__.py +1 -0
  54. package/src/ui/banner.py +87 -0
  55. package/src/ui/commands.py +2694 -0
  56. package/src/ui/displays.py +213 -0
  57. package/src/ui/loader.py +284 -0
  58. package/src/ui/main.py +646 -0
  59. package/src/ui/prompt_utils.py +113 -0
  60. package/src/ui/setting_selector.py +590 -0
  61. package/src/ui/setup_wizard.py +294 -0
  62. package/src/ui/sub_agent_panel.py +234 -0
  63. package/src/ui/tool_confirmation.py +215 -0
  64. package/src/utils/__init__.py +1 -0
  65. package/src/utils/citation_parser.py +199 -0
  66. package/src/utils/editor.py +158 -0
  67. package/src/utils/gitignore_filter.py +149 -0
  68. package/src/utils/logger.py +254 -0
  69. package/src/utils/paths.py +30 -0
  70. package/src/utils/result_parsers.py +108 -0
  71. package/src/utils/safe_commands.py +243 -0
  72. package/src/utils/settings.py +174 -0
  73. package/src/utils/validation.py +191 -0
  74. package/src/utils/web_search.py +173 -0
@@ -0,0 +1,213 @@
1
+ """UI display functions for command outputs."""
2
+
3
+ from rich.table import Table
4
+ from rich.panel import Panel
5
+ from rich import box
6
+ from llm import config
7
+
8
+
9
+ def show_provider_table(current_provider: str, console):
10
+ """Display provider status table.
11
+
12
+ Args:
13
+ current_provider: Name of the currently active provider.
14
+ console: Rich Console instance for output.
15
+ """
16
+ table = Table("Provider", "Status", "Details", title="Providers", box=box.SIMPLE_HEAD)
17
+ for provider in config.get_providers():
18
+ cfg = config.get_provider_config(provider)
19
+ model = cfg.get("model", "N/A")
20
+ if provider == "local":
21
+ status = "✅" if cfg.get("model") else "❌ (set model path)"
22
+ else:
23
+ status = "✅" if cfg.get("api_key") else "❌ (set API key)"
24
+ active = " [green](active)[/green]" if provider == current_provider else ""
25
+ table.add_row(provider.capitalize(), status, f"{model[:40]}{active}")
26
+
27
+ console.print(table)
28
+
29
+ help_text = """Usage: /provider <name>
30
+
31
+ Opens an editor to configure model, API key, and costs.
32
+
33
+ Examples:
34
+ /provider openrouter
35
+ /provider glm
36
+ /provider local
37
+ /provider gemini
38
+ /provider minimax
39
+ /provider anthropic
40
+ /provider kimi"""
41
+ console.print(Panel(help_text, title="[bold #5F9EA0]Provider Settings[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
42
+ console.print("")
43
+
44
+
45
+ def show_help_table(console):
46
+ """Display command help table.
47
+
48
+ Args:
49
+ console: Rich Console instance for output.
50
+ """
51
+ console.print("")
52
+ table = Table(show_header=True, box=box.SIMPLE_HEAD)
53
+ table.add_column("Command", no_wrap=True)
54
+ table.add_column("Description")
55
+
56
+ table.add_row("[bold #5F9EA0]/help[/bold #5F9EA0]", "Show help")
57
+ table.add_row("[bold #5F9EA0]/exit[/bold #5F9EA0]", "Exit chat")
58
+ table.add_row("[bold #5F9EA0]/config[/bold #5F9EA0]", "Show all configuration settings")
59
+ table.add_row("[bold #5F9EA0]/provider[/bold #5F9EA0] [name]", "Configure provider settings (model, key, costs)")
60
+ table.add_row("[bold #5F9EA0]/key[/bold #5F9EA0] <key>", "Set API key for current provider")
61
+ table.add_row("[bold #5F9EA0]/model[/bold #5F9EA0] <name>", "Set model for current provider")
62
+ table.add_row("[bold #5F9EA0]/usage[/bold #5F9EA0] [provider] [in|out] <cost>", "Set/view provider-specific token cost")
63
+ table.add_row("[bold #5F9EA0]/compact[/bold #5F9EA0]", "Compact context with an AI summary")
64
+
65
+
66
+ table.add_row("[bold #5F9EA0]/cd[/bold #5F9EA0] [path]", "Change working directory (no args to show current)")
67
+ table.add_row("[bold #5F9EA0]/edit[/bold #5F9EA0], [bold #5F9EA0]/e[/bold #5F9EA0]", "Open editor for multi-line input")
68
+ table.add_row("[bold #5F9EA0]/review[/bold #5F9EA0] [args], [bold #5F9EA0]/r[/bold #5F9EA0]", "Code review git changes (e.g. /review --staged, /review main..HEAD)")
69
+ table.add_row("[bold #5F9EA0]/obsidian[/bold #5F9EA0] [set|enable|disable|status|init]", "Manage vault integration, scaffold project folders")
70
+ table.add_row("[bold #5F9EA0]/tools[/bold #5F9EA0] [list|enable|disable|enable-group|disable-group]", "Toggle tools or groups (e.g. file_ops, task_mgmt)")
71
+ table.add_row("[bold #5F9EA0]/setup[/bold #5F9EA0]", "Re-run the first-run setup wizard")
72
+ table.add_row("[bold #5F9EA0]/cron[/bold #5F9EA0] [list|add|remove|enable|disable|run]", "Manage scheduled cron jobs")
73
+
74
+
75
+ console.print(Panel(table, title="[bold #5F9EA0]Commands[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
76
+
77
+ # Account management section
78
+ console.print()
79
+ acct_table = Table(show_header=True, box=box.SIMPLE_HEAD)
80
+ acct_table.add_column("Command", no_wrap=True)
81
+ acct_table.add_column("Description")
82
+
83
+ acct_table.add_row("[bold #5F9EA0]/signup[/bold #5F9EA0] <email>", "Create bone-agent account and get API key")
84
+ acct_table.add_row("[bold #5F9EA0]/login[/bold #5F9EA0]", "Log in to an existing bone-agent account")
85
+ acct_table.add_row("[bold #5F9EA0]/account[/bold #5F9EA0]", "View account info and subscription status")
86
+ acct_table.add_row("[bold #5F9EA0]/plan[/bold #5F9EA0]", "View available plans and pricing")
87
+ acct_table.add_row("[bold #5F9EA0]/upgrade[/bold #5F9EA0]", "Upgrade or change your plan")
88
+ acct_table.add_row("[bold #5F9EA0]/manage[/bold #5F9EA0]", "Cancel subscription or update payment (Stripe portal)")
89
+ acct_table.add_row("[bold #5F9EA0]/rotate-key[/bold #5F9EA0]", "Invalidate current API key and generate a new one")
90
+ acct_table.add_row("[bold #5F9EA0]/reset-key[/bold #5F9EA0]", "Get a new API key emailed to you (lost key recovery)")
91
+
92
+ console.print(Panel(acct_table, title="[bold #5F9EA0]Account[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
93
+
94
+ # Keybinds section
95
+ console.print()
96
+ keybinds = Table(show_header=True, box=box.SIMPLE_HEAD)
97
+ keybinds.add_column("Keybind", no_wrap=True)
98
+ keybinds.add_column("Action")
99
+
100
+ keybinds.add_row("Shift+Tab", "Cycle approval mode")
101
+ keybinds.add_row("Ctrl+C", "Interrupt response")
102
+ keybinds.add_row("Ctrl+C (2x)", "Exit program")
103
+
104
+ console.print(Panel(keybinds, title="[bold #5F9EA0]Keybinds[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
105
+ console.print("")
106
+
107
+
108
+ def show_cron_help_table(console):
109
+ """Display cron command help table.
110
+
111
+ Args:
112
+ console: Rich Console instance for output.
113
+ """
114
+ console.print("")
115
+ table = Table(show_header=True, box=box.SIMPLE_HEAD)
116
+ table.add_column("Command", no_wrap=True)
117
+ table.add_column("Description")
118
+
119
+ table.add_row("[bold #5F9EA0]/cron list[/bold #5F9EA0]", "Show all cron jobs (default)")
120
+ table.add_row("[bold #5F9EA0]/cron add[/bold #5F9EA0] <id> <schedule> <cmd>", "Add a new cron job")
121
+ table.add_row("[bold #5F9EA0]/cron remove[/bold #5F9EA0] <id>", "Remove a cron job")
122
+ table.add_row("[bold #5F9EA0]/cron enable[/bold #5F9EA0] <id>", "Enable a cron job")
123
+ table.add_row("[bold #5F9EA0]/cron disable[/bold #5F9EA0] <id>", "Disable a cron job")
124
+ table.add_row("[bold #5F9EA0]/cron run[/bold #5F9EA0] <id>", "Run a job immediately (interactive)")
125
+ table.add_row("[bold #5F9EA0]/cron allowlist[/bold #5F9EA0] [list|add|remove|clear]", "Manage allowed commands for a job")
126
+
127
+ console.print(Panel(table, title="[bold #5F9EA0]Commands[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
128
+
129
+ # Schedule formats section
130
+ console.print()
131
+ sched_table = Table(show_header=True, box=box.SIMPLE_HEAD)
132
+ sched_table.add_column("Format")
133
+ sched_table.add_column("Example")
134
+
135
+ sched_table.add_row("every <n> <unit>", "every 5 minutes, every 1 hour, every 3 days")
136
+ sched_table.add_row("daily at <time>", "daily at 8am, daily at 17:30")
137
+ sched_table.add_row("<day>s at <time>", "weekdays at 9am, mondays at 10:30pm")
138
+ sched_table.add_row("<time>", "08:00, 17:30")
139
+
140
+ console.print(Panel(sched_table, title="[bold #5F9EA0]Schedule Formats[/bold #5F9EA0]", border_style="grey23", padding=(0, 2)))
141
+ console.print("")
142
+
143
+
144
+ def show_config_overview(chat_manager, console, debug_mode_container, current_provider):
145
+ """Display comprehensive configuration overview.
146
+
147
+ Args:
148
+ chat_manager: ChatManager instance for runtime state
149
+ console: Rich Console instance for output
150
+ debug_mode_container: Dict with debug key for debug mode state
151
+ current_provider: Name of the currently active provider
152
+ """
153
+ from core.config_manager import ConfigManager
154
+ config_manager = ConfigManager()
155
+ config_data = config_manager.load()
156
+
157
+ console.print()
158
+
159
+ # ===== Runtime Settings =====
160
+ runtime_table = Table("Setting", "Status", title="Runtime Settings", box=box.SIMPLE_HEAD)
161
+ debug_status = "[green]ON[/green]" if debug_mode_container.get("debug") else "[dim]OFF[/dim]"
162
+ runtime_table.add_row("Debug Mode", debug_status)
163
+ logging_status = "[green]ON[/green]" if chat_manager.markdown_logger else "[dim]OFF[/dim]"
164
+ runtime_table.add_row("Conversation Logging", logging_status)
165
+ approve_labels = {"safe": "SAFE", "accept_edits": "ACCEPT EDITS", "danger": "DANGER"}
166
+ approve_colors = {"safe": "green", "accept_edits": "yellow", "danger": "red"}
167
+ approve_mode = chat_manager.approve_mode
168
+ approve_color = approve_colors.get(approve_mode, "white")
169
+ runtime_table.add_row("Approval Mode", f"[{approve_color}]{approve_labels.get(approve_mode, approve_mode.upper())}[/{approve_color}]")
170
+ console.print(runtime_table)
171
+
172
+ # ===== Provider Settings =====
173
+ console.print()
174
+ provider_table = Table("Provider", "Model", "$ in/out", "API Key", title="Providers", box=box.SIMPLE_HEAD)
175
+
176
+ active_provider = config_data.get("LAST_PROVIDER", "Not set").upper()
177
+ provider_table.add_row("[green]Active[/green]", f"[green]{active_provider}[/green]", "", "")
178
+
179
+ def fmt(v, max_len=35):
180
+ return v[:max_len-3] + "..." if len(v) > max_len else v
181
+
182
+ # Local provider
183
+ local_model = config_data.get("LOCAL_MODEL_PATH", "Not set")
184
+ provider_table.add_row("Local", fmt(local_model), "N/A", "N/A")
185
+
186
+ # API providers
187
+ for provider in ["OpenRouter", "GLM", "OpenAI", "Gemini", "MiniMax", "Anthropic", "Kimi"]:
188
+ model = config_data.get(f"{provider.upper()}_MODEL", "Not set")
189
+ key = config_data.get(f"{provider.upper()}_API_KEY", "")
190
+ key_status = "[green]✓[/green]" if key else "[red]✗[/red]"
191
+
192
+ # Check for model-specific pricing
193
+ model_prices = config_data.get("MODEL_PRICES", {})
194
+ if model and model in model_prices:
195
+ cost_in = model_prices[model].get("cost_in", 0)
196
+ cost_out = model_prices[model].get("cost_out", 0)
197
+ if cost_in > 0 or cost_out > 0:
198
+ cost_str = f"${cost_in:.2f}/${cost_out:.2f}"
199
+ else:
200
+ cost_str = "Not set"
201
+ else:
202
+ cost_str = "Not set"
203
+
204
+ provider_table.add_row(provider, fmt(model), cost_str, key_status)
205
+
206
+ console.print(provider_table)
207
+
208
+ # ===== Quick Commands Reference =====
209
+ console.print()
210
+ help_text = """[bold #5F9EA0]Commands:[/bold #5F9EA0] [bold #5F9EA0]/provider[/bold #5F9EA0] <name> [bold #5F9EA0]/model[/bold #5F9EA0] <path> [bold #5F9EA0]/key[/bold #5F9EA0] <key>
211
+ [#5F9EA0] :[/#5F9EA0] [bold #5F9EA0]/usage[/bold #5F9EA0] [provider] [in|out] <$> [bold #5F9EA0]/config[/bold #5F9EA0]"""
212
+ console.print(Panel(help_text, title="[#5F9EA0]Quick Reference[/#5F9EA0]"))
213
+ console.print()
@@ -0,0 +1,284 @@
1
+ """Animated terminal effects for bone-agent — loading spinners, progress bars, and visual flair."""
2
+
3
+ import time
4
+ import random
5
+ import threading
6
+ from rich.console import Console
7
+ from rich.text import Text
8
+ from rich.live import Live
9
+ from rich.layout import Layout
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.align import Align
13
+
14
+ console = Console()
15
+
16
+
17
+ # ── ASCII art logo ──────────────────────────────────────────────────────────
18
+
19
+ BONE_AGENT_LOGO = r"""
20
+ ╦ ╦┌─┐┌┐ ╔╦╗┬┬ ┌─┐
21
+ ║║║├┤ ├┴┐ ║ ││ ├┤
22
+ ╚╩╝└─┘└─┘ ╩ ┴┴─┘└─┘
23
+ """
24
+
25
+ SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
26
+ DOT_FRAMES = [" ", ". ", ".. ", "..."]
27
+ WAVE_FRAMES = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"]
28
+ BAR_FRAMES = ["◐", "◓", "◑", "◒"]
29
+
30
+
31
+ def _wave_string(text: str, frame_idx: int, color: str = "#5F9EA0") -> Text:
32
+ """Create a wave-animated string where each character oscillates vertically."""
33
+ result = Text()
34
+ n = len(WAVE_FRAMES)
35
+ for i, ch in enumerate(text):
36
+ offset = (frame_idx + i * 2) % n
37
+ bar_char = WAVE_FRAMES[offset]
38
+ # Fade intensity based on wave position (center = bright)
39
+ intensity = abs(offset - n // 2) / (n // 2)
40
+ if ch == " ":
41
+ result.append(" ")
42
+ else:
43
+ result.append(bar_char, style=color)
44
+ return result
45
+
46
+
47
+ def display_intro_animation(provider: str = "", model: str = ""):
48
+ """Play a cinematic intro animation on startup.
49
+
50
+ Shows the bone-agent logo with a typing effect, a wave animation underneath,
51
+ and provider/model info fading in.
52
+ """
53
+ logo_lines = BONE_AGENT_LOGO.strip("\n").split("\n")
54
+
55
+ try:
56
+ with Live(console=console, transient=False, refresh_per_second=24) as live:
57
+ # Phase 1: Logo reveal (typewriter effect)
58
+ revealed_lines = []
59
+ for line_idx, line in enumerate(logo_lines):
60
+ revealed_lines.append("")
61
+ for ch_idx, ch in enumerate(line):
62
+ revealed_lines[line_idx] = line[: ch_idx + 1]
63
+ layout = Layout()
64
+ logo_text = Text("\n".join(revealed_lines), style="bold #5F9EA0")
65
+ layout.update(Align.center(Panel(
66
+ logo_text,
67
+ border_style="grey30",
68
+ padding=(1, 4),
69
+ subtitle=Text(" ", style="dim"),
70
+ )))
71
+ live.update(layout)
72
+ time.sleep(0.008)
73
+ time.sleep(0.04)
74
+
75
+ # Phase 2: Wave animation beneath the logo (runs for ~2 seconds)
76
+ wave_line = "━" * 42
77
+ start = time.time()
78
+ frame = 0
79
+ while time.time() - start < 1.8:
80
+ wave = _wave_string(wave_line, frame, color="#3a7ca5")
81
+ layout = Layout()
82
+ full = Text()
83
+ full.append("\n".join(logo_lines) + "\n", style="bold #5F9EA0")
84
+ full.append(wave)
85
+ layout.update(Align.center(Panel(
86
+ full,
87
+ border_style="grey30",
88
+ padding=(1, 4),
89
+ )))
90
+ live.update(layout)
91
+ frame += 1
92
+ time.sleep(0.05)
93
+
94
+ # Phase 3: Show tagline + provider info
95
+ tagline = Text(" local-first · agent-powered · terminal-native", style="dim grey60")
96
+ if provider and model:
97
+ info = Text.assemble(
98
+ (" ", ""),
99
+ (f"● {provider.upper()} ", "bold #5F9EA0"),
100
+ (f"{model}", "grey70"),
101
+ style="",
102
+ )
103
+ else:
104
+ info = Text("")
105
+
106
+ layout = Layout()
107
+ full = Text()
108
+ full.append("\n".join(logo_lines), style="bold #5F9EA0")
109
+ full.append("\n")
110
+ full.append(wave_line, style="#3a7ca5")
111
+ layout.update(Align.center(Panel(
112
+ Align.center(
113
+ Table.grid(padding=(0, 0)),
114
+ ),
115
+ border_style="grey30",
116
+ padding=(1, 4),
117
+ subtitle=tagline,
118
+ )))
119
+ live.update(layout)
120
+ time.sleep(0.6)
121
+
122
+ # Final frame — static logo with tagline
123
+ final = Text()
124
+ final.append("\n".join(logo_lines), style="bold #5F9EA0")
125
+ final.append("\n")
126
+ final.append(wave_line, style="#3a7ca5")
127
+ layout.update(Align.center(Panel(
128
+ final,
129
+ border_style="grey30",
130
+ padding=(1, 4),
131
+ subtitle=tagline,
132
+ )))
133
+ live.update(layout)
134
+ time.sleep(0.3)
135
+ except Exception:
136
+ # Fallback: if Live fails (e.g. non-TTY), just print static logo
137
+ console.print(Panel(
138
+ Text(BONE_AGENT_LOGO.strip("\n"), style="bold #5F9EA0"),
139
+ border_style="grey30",
140
+ subtitle=" local-first · agent-powered · terminal-native",
141
+ ))
142
+
143
+
144
+ # ── Spinner context manager ─────────────────────────────────────────────────
145
+
146
+ class Spinner:
147
+ """A rich-based status spinner for long-running operations.
148
+
149
+ Usage:
150
+ with Spinner("Indexing files..."):
151
+ do_expensive_work()
152
+ """
153
+
154
+ def __init__(self, message: str, style: str = "#5F9EA0", done_message: str = "done"):
155
+ self.message = message
156
+ self.style = style
157
+ self.done_message = done_message
158
+ self._stop = threading.Event()
159
+ self._thread = None
160
+
161
+ def _spin(self):
162
+ frame = 0
163
+ n = len(SPINNER_FRAMES)
164
+ with Live(console=console, transient=True, refresh_per_second=12) as live:
165
+ while not self._stop.is_set():
166
+ spinner = SPINNER_FRAMES[frame % n]
167
+ live.update(Text(f" {spinner} {self.message}", style=self.style))
168
+ frame += 1
169
+ self._stop.wait(0.08)
170
+
171
+ def __enter__(self):
172
+ self._stop.clear()
173
+ self._thread = threading.Thread(target=self._spin, daemon=True)
174
+ self._thread.start()
175
+ return self
176
+
177
+ def __exit__(self, *args):
178
+ self._stop.set()
179
+ if self._thread:
180
+ self._thread.join(timeout=1)
181
+ console.print(f" ✓ {self.message} {self.done_message}", style="dim green")
182
+
183
+ def ok(self, msg: str = ""):
184
+ """Mark as done with a custom message."""
185
+ self.done_message = msg
186
+
187
+
188
+ # ── Progress bar ────────────────────────────────────────────────────────────
189
+
190
+ class ProgressBar:
191
+ """A lightweight animated progress bar for terminal output.
192
+
193
+ Usage:
194
+ with ProgressBar("Loading", total=100) as bar:
195
+ for i in range(100):
196
+ bar.update(i + 1)
197
+ """
198
+
199
+ def __init__(self, label: str = "", total: int = 100, width: int = 30,
200
+ fill: str = "█", empty: str = "░", color: str = "#5F9EA0"):
201
+ self.label = label
202
+ self.total = total
203
+ self.width = width
204
+ self.fill = fill
205
+ self.empty = empty
206
+ self.color = color
207
+ self.current = 0
208
+ self._stop = threading.Event()
209
+
210
+ def update(self, value: int):
211
+ self.current = min(value, self.total)
212
+
213
+ def _render(self) -> Text:
214
+ pct = self.current / self.total if self.total else 0
215
+ filled = int(self.width * pct)
216
+ bar = self.fill * filled + self.empty * (self.width - filled)
217
+ result = Text()
218
+ if self.label:
219
+ result.append(f" {self.label} ", style="dim")
220
+ result.append(bar, style=self.color)
221
+ result.append(f" {pct:>5.1%}", style="dim grey70")
222
+ return result
223
+
224
+ def _animate(self):
225
+ with Live(console=console, transient=True, refresh_per_second=8) as live:
226
+ while not self._stop.is_set():
227
+ live.update(self._render())
228
+ self._stop.wait(0.1)
229
+
230
+ def __enter__(self):
231
+ self._stop.clear()
232
+ self._thread = threading.Thread(target=self._animate, daemon=True)
233
+ self._thread.start()
234
+ return self
235
+
236
+ def __exit__(self, *args):
237
+ self._stop.set()
238
+ if self._thread:
239
+ self._thread.join(timeout=1)
240
+ # Print final state
241
+ console.print(self._render())
242
+
243
+
244
+ # ── Scan-line / matrix effect (decorative) ─────────────────────────────────
245
+
246
+ def matrix_rain(duration: float = 1.5, cols: int = 60, rows: int = 8, chars: str = "01アイウエオカキクケコ"):
247
+ """Print a brief Matrix-style rain effect to the terminal.
248
+
249
+ Purely decorative — great for transitions between sections.
250
+ """
251
+ random.seed()
252
+ try:
253
+ with Live(console=console, transient=True, refresh_per_second=16) as live:
254
+ start = time.time()
255
+ # Each column has a falling "drop" at a random row
256
+ drops = [random.randint(0, rows - 1) for _ in range(cols)]
257
+ speeds = [random.uniform(0.3, 1.0) for _ in range(cols)]
258
+ phases = [random.random() * duration for _ in range(cols)]
259
+
260
+ while time.time() - start < duration:
261
+ grid = []
262
+ t = time.time() - start
263
+ for r in range(rows):
264
+ row = Text()
265
+ for c in range(cols):
266
+ # Determine if this cell is "active"
267
+ drop_pos = drops[c] + int((t - phases[c]) * speeds[c] * rows / duration)
268
+ drop_pos = drop_pos % (rows + 4) # wrap around
269
+ dist = drop_pos - r
270
+ if 0 <= dist <= 3:
271
+ ch = random.choice(chars)
272
+ if dist == 0:
273
+ row.append(ch, style="bold white")
274
+ else:
275
+ fade = f"#{max(0, 0x10):02x}{max(0, 0x40 + (3 - dist) * 0x20):02x}{max(0, 0x10):02x}"
276
+ row.append(ch, style=fade)
277
+ else:
278
+ row.append(" ")
279
+ grid.append(row)
280
+
281
+ live.update(Text("\n").join(grid))
282
+ time.sleep(0.04)
283
+ except Exception:
284
+ pass # silently skip if terminal doesn't support it