shellmate-cli 3.0.0__py3-none-any.whl

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 (106) hide show
  1. shellmate/__init__.py +0 -0
  2. shellmate/adapters/__init__.py +67 -0
  3. shellmate/adapters/bash_adapter.sh +7 -0
  4. shellmate/adapters/ble_adapter.sh +350 -0
  5. shellmate/adapters/powershell_adapter.ps1 +417 -0
  6. shellmate/adapters/powershell_predictor.ps1 +97 -0
  7. shellmate/adapters/zsh_adapter.zsh +8 -0
  8. shellmate/agents/__init__.py +23 -0
  9. shellmate/agents/base.py +248 -0
  10. shellmate/agents/chain_patterns.yaml +98 -0
  11. shellmate/agents/command_chain.py +322 -0
  12. shellmate/agents/context_gatherer.py +175 -0
  13. shellmate/agents/environment.py +363 -0
  14. shellmate/agents/librarian.py +502 -0
  15. shellmate/agents/query.py +415 -0
  16. shellmate/agents/repomix_backend.py +171 -0
  17. shellmate/agents/watcher.py +266 -0
  18. shellmate/cache/__init__.py +0 -0
  19. shellmate/cache/semantic_cache.py +41 -0
  20. shellmate/cli/__init__.py +0 -0
  21. shellmate/cli/commands/__init__.py +27 -0
  22. shellmate/cli/commands/analyze.py +5 -0
  23. shellmate/cli/commands/config.py +319 -0
  24. shellmate/cli/commands/configure.py +118 -0
  25. shellmate/cli/commands/disable.py +11 -0
  26. shellmate/cli/commands/enable.py +49 -0
  27. shellmate/cli/commands/init.py +412 -0
  28. shellmate/cli/commands/install.py +242 -0
  29. shellmate/cli/commands/migrate.py +147 -0
  30. shellmate/cli/commands/status.py +31 -0
  31. shellmate/cli/commands/uninstall.py +78 -0
  32. shellmate/cli/main.py +114 -0
  33. shellmate/cli/parser.py +184 -0
  34. shellmate/cli/version.py +1 -0
  35. shellmate/completion/__init__.py +4 -0
  36. shellmate/completion/bash_adapter.py +145 -0
  37. shellmate/config/__init__.py +33 -0
  38. shellmate/config/credentials.py +120 -0
  39. shellmate/config/defaults.py +88 -0
  40. shellmate/config/manager.py +94 -0
  41. shellmate/config/paths.py +77 -0
  42. shellmate/config/secrets.py +66 -0
  43. shellmate/daemon/__init__.py +0 -0
  44. shellmate/daemon/lifecycle.py +308 -0
  45. shellmate/daemon/observability.py +82 -0
  46. shellmate/daemon/pipeline.py +119 -0
  47. shellmate/daemon/server.py +605 -0
  48. shellmate/daemon/session.py +170 -0
  49. shellmate/daemon/startup_ui.py +43 -0
  50. shellmate/environment/__init__.py +0 -0
  51. shellmate/environment/detector.py +130 -0
  52. shellmate/ipc/__init__.py +0 -0
  53. shellmate/ipc/client.py +210 -0
  54. shellmate/ipc/ps_client.py +190 -0
  55. shellmate/orchestration/__init__.py +3 -0
  56. shellmate/orchestration/analysis_graph.py +1274 -0
  57. shellmate/orchestration/state.py +35 -0
  58. shellmate/parser/__init__.py +0 -0
  59. shellmate/parser/command_parser.py +111 -0
  60. shellmate/plugins/__init__.py +0 -0
  61. shellmate/plugins/base.py +80 -0
  62. shellmate/plugins/loader.py +41 -0
  63. shellmate/providers/__init__.py +0 -0
  64. shellmate/providers/base.py +97 -0
  65. shellmate/providers/groq_command_guide.py +108 -0
  66. shellmate/providers/groq_provider.py +127 -0
  67. shellmate/providers/groq_supervisor.py +78 -0
  68. shellmate/providers/openrouter_provider.py +149 -0
  69. shellmate/providers/router.py +272 -0
  70. shellmate/providers/validate_keys.py +98 -0
  71. shellmate/providers/xai_provider.py +142 -0
  72. shellmate/rules/__init__.py +0 -0
  73. shellmate/rules/catastrophic.yaml +96 -0
  74. shellmate/rules/catastrophic_matcher.py +167 -0
  75. shellmate/rules/definitions/__init__.py +0 -0
  76. shellmate/rules/definitions/file_deletion.yaml +54 -0
  77. shellmate/rules/definitions/filesystem.yaml +43 -0
  78. shellmate/rules/definitions/network_execution.yaml +46 -0
  79. shellmate/rules/definitions/permissions.yaml +54 -0
  80. shellmate/rules/definitions/process_control.yaml +37 -0
  81. shellmate/rules/definitions/system_destruction.yaml +67 -0
  82. shellmate/rules/engine.py +200 -0
  83. shellmate/sanitizer/__init__.py +0 -0
  84. shellmate/sanitizer/typo_detector.py +114 -0
  85. shellmate/ui/__init__.py +10 -0
  86. shellmate/ui/analysis.py +164 -0
  87. shellmate/ui/annotations.py +232 -0
  88. shellmate/ui/banner.py +49 -0
  89. shellmate/ui/progress.py +126 -0
  90. shellmate/ui/runtime/__init__.py +15 -0
  91. shellmate/ui/runtime/console_factory.py +65 -0
  92. shellmate/ui/runtime/live_scene.py +116 -0
  93. shellmate/ui/runtime/motion_policy.py +69 -0
  94. shellmate/ui/runtime/tokens.py +47 -0
  95. shellmate/ui/scenes/__init__.py +24 -0
  96. shellmate/ui/scenes/enable.py +316 -0
  97. shellmate/ui/scenes/help.py +53 -0
  98. shellmate/ui/scenes/stages.py +128 -0
  99. shellmate/ui/scenes/status.py +181 -0
  100. shellmate/ui/status.py +3 -0
  101. shellmate_cli-3.0.0.dist-info/METADATA +352 -0
  102. shellmate_cli-3.0.0.dist-info/RECORD +106 -0
  103. shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
  104. shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
  105. shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
  106. shellmate_cli-3.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,232 @@
1
+ """Inline annotation formatting for autocomplete mode."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, Optional
6
+
7
+ # Emoji mappings for risk levels (short inline / Unix terminals)
8
+ RISK_ICONS = {
9
+ "SAFE": "✅",
10
+ "LOW": "ℹ️",
11
+ "MEDIUM": "⚠️",
12
+ "HIGH": "🔥",
13
+ "CRITICAL": "⛔",
14
+ }
15
+
16
+ # ASCII-safe risk icons (Windows PS 5.1 / constrained consoles)
17
+ RISK_ICONS_ASCII = {
18
+ "SAFE": "[OK]",
19
+ "LOW": "[i]",
20
+ "MEDIUM": "[!]",
21
+ "HIGH": "[!!]",
22
+ "CRITICAL": "[X]",
23
+ }
24
+
25
+ SEVERITY_ICONS = {
26
+ "blocking": "🔥",
27
+ "warning": "⚠️",
28
+ "info": "ℹ️",
29
+ }
30
+
31
+ SEVERITY_ICONS_ASCII = {
32
+ "blocking": "[X]",
33
+ "warning": "[!]",
34
+ "info": "[i]",
35
+ }
36
+
37
+ # Environment badges (emoji form for short annotations)
38
+ ENV_BADGES = {
39
+ "production": "🏭",
40
+ "staging": "🧪",
41
+ "ci": "🤖",
42
+ "development": "💻",
43
+ }
44
+
45
+ # Text badges used in consequence / pipeline annotations (cross-platform)
46
+ ENV_TEXT_BADGES = {
47
+ "production": "[PROD]",
48
+ "staging": "[STAGE]",
49
+ "development": "[DEV]",
50
+ "ci": "[CI]",
51
+ }
52
+
53
+ # Time context badges
54
+ TIME_BADGES = {
55
+ "business_hours": "⏰",
56
+ "after_hours": "🌙",
57
+ }
58
+
59
+
60
+ def format_annotation(
61
+ risk_analysis: Dict[str, Any],
62
+ environment_context: Optional[Dict[str, Any]] = None,
63
+ max_chars: int = 30,
64
+ *,
65
+ ascii_safe: bool = False,
66
+ ) -> str:
67
+ """
68
+ Format risk analysis into a short inline annotation.
69
+
70
+ Returns e.g. "🔥 rewrites history 🏭⏰" (truncated to max_chars).
71
+ """
72
+ severity = risk_analysis.get("severity") or risk_analysis.get("risk", "MEDIUM")
73
+ reason = risk_analysis.get("reason", "")
74
+
75
+ icons = RISK_ICONS_ASCII if ascii_safe else RISK_ICONS
76
+ parts = [icons.get(severity, "[!]" if ascii_safe else "⚠️")]
77
+
78
+ if reason:
79
+ short_reason = reason[:20] if len(reason) > 20 else reason
80
+ parts.append(short_reason)
81
+
82
+ if environment_context:
83
+ env_type = environment_context.get("type")
84
+ if ascii_safe:
85
+ if env_type in ENV_TEXT_BADGES:
86
+ parts.append(ENV_TEXT_BADGES[env_type])
87
+ elif env_type in ENV_BADGES:
88
+ parts.append(ENV_BADGES[env_type])
89
+
90
+ time_ctx = environment_context.get("time", {}) or {}
91
+ if time_ctx:
92
+ if time_ctx.get("is_business_hours"):
93
+ parts.append("BH" if ascii_safe else TIME_BADGES["business_hours"])
94
+ elif time_ctx.get("after_hours") or time_ctx.get("is_business_hours") is False:
95
+ parts.append("AH" if ascii_safe else TIME_BADGES["after_hours"])
96
+
97
+ annotation = " ".join(parts)
98
+ return annotation[:max_chars]
99
+
100
+
101
+ def format_consequence_annotation(
102
+ consequence: Dict[str, Any],
103
+ env_context: Optional[Dict[str, Any]] = None,
104
+ chain_patterns: Optional[Dict[str, Any]] = None,
105
+ risk: str = "MEDIUM",
106
+ *,
107
+ ascii_safe: bool = False,
108
+ ) -> str:
109
+ """Format annotation using structured consequence preview + badges."""
110
+ env_context = env_context or {}
111
+ chain_patterns = chain_patterns or {}
112
+
113
+ severity = consequence.get("severity", "warning")
114
+ icons = SEVERITY_ICONS_ASCII if ascii_safe else SEVERITY_ICONS
115
+ icon = icons.get(severity)
116
+ if not icon:
117
+ risk_icons = RISK_ICONS_ASCII if ascii_safe else RISK_ICONS
118
+ icon = risk_icons.get(risk, "[!]" if ascii_safe else "ℹ️")
119
+
120
+ parts = [icon]
121
+
122
+ env_type = env_context.get("type", "")
123
+ if env_type in ENV_TEXT_BADGES:
124
+ parts.append(ENV_TEXT_BADGES[env_type])
125
+
126
+ if chain_patterns.get("broken", []):
127
+ parts.append("[BROKEN]")
128
+ elif chain_patterns.get("dangerous_escalations", []):
129
+ parts.append("[ESCALATION]")
130
+
131
+ what = consequence.get("what_happens", "") or ""
132
+ if what:
133
+ if len(what) > 50:
134
+ what = what[:47] + "..."
135
+ parts.append(what)
136
+
137
+ if not consequence.get("reversible", True):
138
+ parts.append("[IRREVERSIBLE]")
139
+
140
+ return " ".join(parts) if len(parts) > 1 else ""
141
+
142
+
143
+ def format_legacy_pipeline_annotation(
144
+ risk_result: Dict[str, Any],
145
+ env_context: Optional[Dict[str, Any]] = None,
146
+ chain_patterns: Optional[Dict[str, Any]] = None,
147
+ *,
148
+ ascii_safe: bool = False,
149
+ ) -> str:
150
+ """Format annotation from pipeline result without consequence_preview."""
151
+ env_context = env_context or {}
152
+ chain_patterns = chain_patterns or {}
153
+
154
+ risk_analysis = risk_result.get("risk_analysis", {}) or {}
155
+ msg = risk_analysis.get("reason", risk_result.get("message", "")) or ""
156
+ risk = risk_result.get("risk", "MEDIUM")
157
+
158
+ icons = RISK_ICONS_ASCII if ascii_safe else RISK_ICONS
159
+ # Map HIGH->🔥 style: prefer risk-level icons; CRITICAL uses ⛔ in short form
160
+ # Pipeline historically used 🔥 for CRITICAL — keep that for non-ascii.
161
+ if not ascii_safe:
162
+ pipeline_icons = {
163
+ "CRITICAL": "🔥",
164
+ "HIGH": "⚠️",
165
+ "MEDIUM": "ℹ️",
166
+ "LOW": "✓",
167
+ "SAFE": "✓",
168
+ }
169
+ icon = pipeline_icons.get(risk, "ℹ️")
170
+ else:
171
+ icon = icons.get(risk, "[i]")
172
+
173
+ parts = [icon]
174
+
175
+ env_type = env_context.get("type", "")
176
+ if env_type in ENV_TEXT_BADGES:
177
+ parts.append(ENV_TEXT_BADGES[env_type])
178
+
179
+ if chain_patterns.get("broken", []):
180
+ parts.append("[BROKEN-WORKFLOW]")
181
+ elif chain_patterns.get("dangerous_escalations", []):
182
+ parts.append("[ESCALATION]")
183
+
184
+ if msg:
185
+ if len(msg) > 40:
186
+ msg = msg[:37] + "..."
187
+ parts.append(msg)
188
+
189
+ return " ".join(parts) if len(parts) > 1 else ""
190
+
191
+
192
+ def format_pipeline_annotation(
193
+ risk_result: Dict[str, Any],
194
+ env_context: Optional[Dict[str, Any]] = None,
195
+ chain_patterns: Optional[Dict[str, Any]] = None,
196
+ *,
197
+ ascii_safe: bool = False,
198
+ ) -> str:
199
+ """Prefer consequence_preview; fall back to legacy pipeline formatting."""
200
+ consequence = risk_result.get("consequence_preview") or {}
201
+ if consequence.get("what_happens"):
202
+ return format_consequence_annotation(
203
+ consequence,
204
+ env_context,
205
+ chain_patterns,
206
+ risk_result.get("risk", "MEDIUM"),
207
+ ascii_safe=ascii_safe,
208
+ )
209
+ return format_legacy_pipeline_annotation(
210
+ risk_result, env_context, chain_patterns, ascii_safe=ascii_safe
211
+ )
212
+
213
+
214
+ def format_cached_annotation(cached_result: Dict[str, Any], *, ascii_safe: bool = False) -> str:
215
+ """Format a simple annotation from a cache hit payload."""
216
+ msg = cached_result.get("explanation", "") or ""
217
+ risk = cached_result.get("risk", "MEDIUM")
218
+ if ascii_safe:
219
+ icon = RISK_ICONS_ASCII.get(risk, "[i]")
220
+ else:
221
+ icons = {
222
+ "CRITICAL": "🔥",
223
+ "HIGH": "⚠️",
224
+ "MEDIUM": "ℹ️",
225
+ "LOW": "✓",
226
+ "SAFE": "✓",
227
+ }
228
+ icon = icons.get(risk, "ℹ️")
229
+
230
+ if len(msg) > 50:
231
+ msg = msg[:47] + "..."
232
+ return f"{icon} {msg}" if msg else ""
shellmate/ui/banner.py ADDED
@@ -0,0 +1,49 @@
1
+ from rich.console import Console
2
+ from rich.text import Text
3
+ from rich.panel import Panel
4
+
5
+ from shellmate.cli.version import VERSION
6
+ from shellmate.ui.runtime import tokens
7
+ from shellmate.ui.runtime.console_factory import ConsoleFactory
8
+ from shellmate.ui.scenes.help import print_help, render_help
9
+
10
+ console = ConsoleFactory().create()
11
+
12
+ # Kept for any external imports; enable scene uses its own compact logo.
13
+ BANNER_ART = "[bold cyan]ShellMate[/]"
14
+
15
+
16
+ def print_banner():
17
+ """Minimal wordmark banner (no giant ASCII). Bold logo lives on sm enable only."""
18
+ width = tokens.content_width(console.width)
19
+ body = Text.assemble(
20
+ Text("ShellMate", style=f"bold {tokens.ACCENT}"),
21
+ Text(f" v{VERSION}", style=tokens.MUTED),
22
+ Text("\n"),
23
+ Text("Intelligent command safety layer", style=tokens.MUTED),
24
+ )
25
+ console.print(
26
+ Panel(body, border_style=tokens.ACCENT, padding=(1, 2), width=width, expand=False)
27
+ )
28
+
29
+
30
+ def render_enable_panel(stages: list[tuple[str, bool]]) -> Panel:
31
+ """Deprecated compatibility wrapper — prefer ui.scenes.enable.render_enable_frame."""
32
+ from shellmate.ui.scenes.stages import StageStatus, StageView, EnableViewState
33
+ from shellmate.ui.scenes.enable import render_enable_frame
34
+
35
+ views = [
36
+ StageView(
37
+ key=label,
38
+ label=label,
39
+ status=StageStatus.DONE if done else StageStatus.INCOMPLETE,
40
+ )
41
+ for label, done in stages
42
+ ]
43
+ state = EnableViewState(stages=views, ready=all(d for _, d in stages), timed_out=not all(d for _, d in stages))
44
+ frame = render_enable_frame(state, console_width=console.width, settled=True)
45
+ return frame # type: ignore[return-value]
46
+
47
+
48
+ def print_usage():
49
+ print_help(console)
@@ -0,0 +1,126 @@
1
+ import time
2
+ import threading
3
+ from contextlib import contextmanager
4
+ from rich.console import Console
5
+ from rich.status import Status
6
+ from rich.live import Live
7
+ from rich.table import Table
8
+ from rich.panel import Panel
9
+ from rich import box
10
+
11
+ from shellmate.ui.runtime import tokens
12
+
13
+ STAGES = [
14
+ ("> Routing command...", "dots12"),
15
+ ("* Gathering context...", "dots12"),
16
+ ("* Analyzing risk...", "dots12"),
17
+ ("> Building usage guide...", "dots12"),
18
+ ("* Formatting result...", "dots12"),
19
+ ]
20
+
21
+ STAGE_TIMINGS = {
22
+ "sanitizer": 2,
23
+ "parser": 5,
24
+ "rules": 10,
25
+ "cache": 1,
26
+ "command_chain": 10,
27
+ "supervisor": 50,
28
+ "watcher": 200,
29
+ "environment": 30,
30
+ "librarian": 300,
31
+ "risk_analyzer": 200,
32
+ "remediation": 150,
33
+ "command_guide": 100,
34
+ "formatter": 5,
35
+ "query_agent": 100,
36
+ }
37
+
38
+
39
+ @contextmanager
40
+ def analysis_progress(console: Console, interval: float = 0.4, verbose: bool = False):
41
+ """Progress indicator with optional live agent view."""
42
+ # Plain / non-TTY: no animation
43
+ if not console.is_terminal or console.no_color:
44
+ yield
45
+ return
46
+
47
+ if verbose:
48
+ with _live_progress_table(console):
49
+ yield
50
+ else:
51
+ stop = threading.Event()
52
+ idx = [0]
53
+
54
+ def cycle(status: Status):
55
+ while not stop.is_set():
56
+ text, spinner = STAGES[idx[0] % len(STAGES)]
57
+ status.update(text, spinner=spinner)
58
+ idx[0] += 1
59
+ stop.wait(interval)
60
+
61
+ with console.status(STAGES[0][0], spinner=STAGES[0][1]) as status:
62
+ t = threading.Thread(target=cycle, args=(status,), daemon=True)
63
+ t.start()
64
+ try:
65
+ yield
66
+ finally:
67
+ stop.set()
68
+ t.join(timeout=1.0)
69
+
70
+
71
+ @contextmanager
72
+ def _live_progress_table(console: Console):
73
+ icon_map = tokens.icons(ascii_safe=console.no_color)
74
+ status_icons = {
75
+ "pending": icon_map["pending"],
76
+ "running": icon_map["running"],
77
+ "done": icon_map["done"],
78
+ "cached": icon_map["done"],
79
+ "skipped": icon_map["skip"],
80
+ "error": icon_map["error"],
81
+ }
82
+
83
+ agent_state = {
84
+ stage: {"status": "pending", "elapsed_ms": 0}
85
+ for stage in STAGE_TIMINGS.keys()
86
+ }
87
+
88
+ def build_table() -> Panel:
89
+ table = Table(show_header=True, box=box.SIMPLE, expand=False)
90
+ table.add_column("Stage", style=tokens.ACCENT, no_wrap=True)
91
+ table.add_column("Status", style="white", justify="center", width=8)
92
+ table.add_column("Time", justify="right", style=tokens.WARNING, width=10)
93
+
94
+ for stage, data in agent_state.items():
95
+ status = data["status"]
96
+ icon = status_icons.get(status, "·")
97
+ elapsed = f"{data['elapsed_ms']}ms" if data["elapsed_ms"] > 0 else "—"
98
+ stage_name = stage.replace("_", " ").title()
99
+ table.add_row(stage_name, icon, elapsed)
100
+
101
+ width = tokens.content_width(console.width)
102
+ return Panel(
103
+ table,
104
+ title="[bold]Analysis[/]",
105
+ border_style=tokens.ACCENT,
106
+ width=width,
107
+ expand=False,
108
+ )
109
+
110
+ with Live(build_table(), console=console, refresh_per_second=8, transient=True) as live:
111
+ try:
112
+ for stage, duration_ms in STAGE_TIMINGS.items():
113
+ agent_state[stage]["status"] = "running"
114
+ live.update(build_table())
115
+ # Cap simulated waits so verbose mode cannot dominate wall clock
116
+ time.sleep(min(duration_ms, 40) / 1000.0)
117
+ agent_state[stage]["status"] = "done"
118
+ agent_state[stage]["elapsed_ms"] = duration_ms
119
+ live.update(build_table())
120
+ yield
121
+ except KeyboardInterrupt:
122
+ for stage in agent_state:
123
+ if agent_state[stage]["status"] == "running":
124
+ agent_state[stage]["status"] = "error"
125
+ live.update(build_table())
126
+ raise
@@ -0,0 +1,15 @@
1
+ """Shared Rich presentation runtime for ShellMate CLI scenes."""
2
+
3
+ from shellmate.ui.runtime.console_factory import ConsoleFactory, is_plain_mode
4
+ from shellmate.ui.runtime.live_scene import LiveScene
5
+ from shellmate.ui.runtime.motion_policy import MotionPolicy, motion_from_env
6
+ from shellmate.ui.runtime import tokens
7
+
8
+ __all__ = [
9
+ "ConsoleFactory",
10
+ "is_plain_mode",
11
+ "LiveScene",
12
+ "MotionPolicy",
13
+ "motion_from_env",
14
+ "tokens",
15
+ ]
@@ -0,0 +1,65 @@
1
+ """Console construction with plain-mode detection and real terminal width."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from dataclasses import dataclass
8
+
9
+ from rich.console import Console
10
+
11
+
12
+ def is_plain_mode(
13
+ *,
14
+ no_color: bool = False,
15
+ force_terminal: bool | None = None,
16
+ environ: dict | None = None,
17
+ stdout=None,
18
+ ) -> bool:
19
+ """True when animation/color should be disabled.
20
+
21
+ Triggers: --no-color, NO_COLOR, CI, or non-TTY stdout.
22
+ """
23
+ env = environ if environ is not None else os.environ
24
+ if no_color:
25
+ return True
26
+ if env.get("NO_COLOR", ""):
27
+ return True
28
+ if env.get("CI", ""):
29
+ return True
30
+ stream = stdout if stdout is not None else sys.stdout
31
+ if force_terminal is False:
32
+ return True
33
+ if force_terminal is True:
34
+ return False
35
+ try:
36
+ return not stream.isatty()
37
+ except Exception:
38
+ return True
39
+
40
+
41
+ @dataclass
42
+ class ConsoleFactory:
43
+ no_color: bool = False
44
+ force_terminal: bool | None = None
45
+ width: int | None = None
46
+
47
+ def is_plain(self) -> bool:
48
+ return is_plain_mode(no_color=self.no_color, force_terminal=self.force_terminal)
49
+
50
+ def create(self) -> Console:
51
+ plain = self.is_plain()
52
+ kwargs: dict = {
53
+ "no_color": plain or self.no_color,
54
+ "highlight": False,
55
+ }
56
+ if self.force_terminal is not None:
57
+ kwargs["force_terminal"] = self.force_terminal
58
+ console = Console(**kwargs)
59
+ # Soft-wrap; never pin 110/120. Optional override via size when provided.
60
+ if self.width is not None:
61
+ try:
62
+ console.width = self.width
63
+ except Exception:
64
+ pass
65
+ return console
@@ -0,0 +1,116 @@
1
+ """One Live region per command: morph updates, then settle to a static frame."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from rich.console import Console, RenderableType
8
+ from rich.live import Live
9
+
10
+
11
+ class LiveScene:
12
+ """Owns a single Rich Live (or static printer in plain mode).
13
+
14
+ Guarantees: settle shows the final frame once; render failures fall back
15
+ to static print and never raise to the caller by default.
16
+ """
17
+
18
+ def __init__(self, console: Console, *, plain: bool = False, refresh_per_second: int = 12):
19
+ self.console = console
20
+ self.plain = plain
21
+ self.refresh_per_second = refresh_per_second
22
+ self._live: Optional[Live] = None
23
+ self._started = False
24
+ self._settled = False
25
+ self._fallback = False
26
+ self._transient = False
27
+
28
+ def start(self, initial: RenderableType) -> None:
29
+ if self._started:
30
+ return
31
+ self._started = True
32
+ if self.plain:
33
+ return
34
+ try:
35
+ # transient=False so Windows/Cursor terminals keep painting mid-frames.
36
+ # settle() updates in-place then stops without reprinting a duplicate.
37
+ self._transient = False
38
+ self._live = Live(
39
+ initial,
40
+ console=self.console,
41
+ refresh_per_second=self.refresh_per_second,
42
+ transient=self._transient,
43
+ auto_refresh=True,
44
+ )
45
+ self._live.start()
46
+ try:
47
+ self._live.refresh()
48
+ except Exception:
49
+ pass
50
+ except Exception:
51
+ self._fallback = True
52
+ self._live = None
53
+
54
+ def update(self, renderable: RenderableType) -> None:
55
+ if self.plain or self._fallback or self._settled:
56
+ return
57
+ if self._live is None:
58
+ return
59
+ try:
60
+ self._live.update(renderable, refresh=True)
61
+ except TypeError:
62
+ try:
63
+ self._live.update(renderable)
64
+ self._live.refresh()
65
+ except Exception:
66
+ self._fallback = True
67
+ self._stop_live_quiet()
68
+ except Exception:
69
+ self._fallback = True
70
+ self._stop_live_quiet()
71
+
72
+ def settle(self, final: RenderableType) -> None:
73
+ """Show the final frame once (in-place if Live is active)."""
74
+ if self._settled:
75
+ return
76
+ self._settled = True
77
+
78
+ if self._live is not None and not self._fallback:
79
+ try:
80
+ try:
81
+ self._live.update(final, refresh=True)
82
+ except TypeError:
83
+ self._live.update(final)
84
+ self._live.refresh()
85
+ # Leave non-transient final frame on screen; do not reprint.
86
+ self._stop_live_quiet()
87
+ if self._transient:
88
+ self.console.print(final)
89
+ return
90
+ except Exception:
91
+ self._stop_live_quiet()
92
+
93
+ try:
94
+ self.console.print(final)
95
+ except Exception:
96
+ try:
97
+ with self.console.capture() as cap:
98
+ self.console.print(final)
99
+ self.console.print(cap.get())
100
+ except Exception:
101
+ pass
102
+
103
+ def _stop_live_quiet(self) -> None:
104
+ if self._live is None:
105
+ return
106
+ try:
107
+ self._live.stop()
108
+ except Exception:
109
+ pass
110
+ self._live = None
111
+
112
+ def __enter__(self) -> "LiveScene":
113
+ return self
114
+
115
+ def __exit__(self, *exc: Any) -> None:
116
+ self._stop_live_quiet()
@@ -0,0 +1,69 @@
1
+ """Timing rules for sequential stage reveals (hybrid dwell + artificial cap)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ from dataclasses import dataclass
8
+
9
+
10
+ def motion_from_env(*, plain: bool = False) -> "MotionPolicy":
11
+ """Build policy; SM_UI_SLOW=1 roughly doubles dwell for demo visibility."""
12
+ slow = os.environ.get("SM_UI_SLOW", "").strip().lower() in ("1", "true", "yes")
13
+ if slow:
14
+ return MotionPolicy(
15
+ min_dwell_ms=350,
16
+ stagger_ms=120,
17
+ max_artificial_ms_after_ready=2800,
18
+ plain=plain,
19
+ )
20
+ return MotionPolicy(
21
+ min_dwell_ms=180,
22
+ stagger_ms=70,
23
+ max_artificial_ms_after_ready=1600,
24
+ plain=plain,
25
+ )
26
+
27
+
28
+ @dataclass
29
+ class MotionPolicy:
30
+ """Hybrid timing: short min dwell/stagger, hard cap on artificial wait after ready."""
31
+
32
+ min_dwell_ms: int = 180
33
+ stagger_ms: int = 70
34
+ max_artificial_ms_after_ready: int = 1600
35
+ plain: bool = False
36
+
37
+ def dwell_seconds(self) -> float:
38
+ if self.plain:
39
+ return 0.0
40
+ return max(0, self.min_dwell_ms) / 1000.0
41
+
42
+ def stagger_seconds(self) -> float:
43
+ if self.plain:
44
+ return 0.0
45
+ return max(0, self.stagger_ms) / 1000.0
46
+
47
+ def sleep(self, seconds: float) -> None:
48
+ if self.plain or seconds <= 0:
49
+ return
50
+ time.sleep(seconds)
51
+
52
+ def clamp_artificial_budget(self, already_spent_ms: float) -> float:
53
+ """Seconds still allowed for artificial waits after ready was observed."""
54
+ if self.plain:
55
+ return 0.0
56
+ remaining = self.max_artificial_ms_after_ready - already_spent_ms
57
+ return max(0.0, remaining) / 1000.0
58
+
59
+ def play_reveal_delays(self, n_stages: int) -> list[float]:
60
+ """Per-stage delays for ordered reveal when jumping ahead (e.g. already ready)."""
61
+ if self.plain or n_stages <= 0:
62
+ return [0.0] * max(0, n_stages)
63
+ per = self.dwell_seconds() + self.stagger_seconds()
64
+ raw_total = per * n_stages
65
+ budget = self.max_artificial_ms_after_ready / 1000.0
66
+ if raw_total <= budget or raw_total <= 0:
67
+ return [per] * n_stages
68
+ scaled = budget / n_stages
69
+ return [scaled] * n_stages