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.
- shellmate/__init__.py +0 -0
- shellmate/adapters/__init__.py +67 -0
- shellmate/adapters/bash_adapter.sh +7 -0
- shellmate/adapters/ble_adapter.sh +350 -0
- shellmate/adapters/powershell_adapter.ps1 +417 -0
- shellmate/adapters/powershell_predictor.ps1 +97 -0
- shellmate/adapters/zsh_adapter.zsh +8 -0
- shellmate/agents/__init__.py +23 -0
- shellmate/agents/base.py +248 -0
- shellmate/agents/chain_patterns.yaml +98 -0
- shellmate/agents/command_chain.py +322 -0
- shellmate/agents/context_gatherer.py +175 -0
- shellmate/agents/environment.py +363 -0
- shellmate/agents/librarian.py +502 -0
- shellmate/agents/query.py +415 -0
- shellmate/agents/repomix_backend.py +171 -0
- shellmate/agents/watcher.py +266 -0
- shellmate/cache/__init__.py +0 -0
- shellmate/cache/semantic_cache.py +41 -0
- shellmate/cli/__init__.py +0 -0
- shellmate/cli/commands/__init__.py +27 -0
- shellmate/cli/commands/analyze.py +5 -0
- shellmate/cli/commands/config.py +319 -0
- shellmate/cli/commands/configure.py +118 -0
- shellmate/cli/commands/disable.py +11 -0
- shellmate/cli/commands/enable.py +49 -0
- shellmate/cli/commands/init.py +412 -0
- shellmate/cli/commands/install.py +242 -0
- shellmate/cli/commands/migrate.py +147 -0
- shellmate/cli/commands/status.py +31 -0
- shellmate/cli/commands/uninstall.py +78 -0
- shellmate/cli/main.py +114 -0
- shellmate/cli/parser.py +184 -0
- shellmate/cli/version.py +1 -0
- shellmate/completion/__init__.py +4 -0
- shellmate/completion/bash_adapter.py +145 -0
- shellmate/config/__init__.py +33 -0
- shellmate/config/credentials.py +120 -0
- shellmate/config/defaults.py +88 -0
- shellmate/config/manager.py +94 -0
- shellmate/config/paths.py +77 -0
- shellmate/config/secrets.py +66 -0
- shellmate/daemon/__init__.py +0 -0
- shellmate/daemon/lifecycle.py +308 -0
- shellmate/daemon/observability.py +82 -0
- shellmate/daemon/pipeline.py +119 -0
- shellmate/daemon/server.py +605 -0
- shellmate/daemon/session.py +170 -0
- shellmate/daemon/startup_ui.py +43 -0
- shellmate/environment/__init__.py +0 -0
- shellmate/environment/detector.py +130 -0
- shellmate/ipc/__init__.py +0 -0
- shellmate/ipc/client.py +210 -0
- shellmate/ipc/ps_client.py +190 -0
- shellmate/orchestration/__init__.py +3 -0
- shellmate/orchestration/analysis_graph.py +1274 -0
- shellmate/orchestration/state.py +35 -0
- shellmate/parser/__init__.py +0 -0
- shellmate/parser/command_parser.py +111 -0
- shellmate/plugins/__init__.py +0 -0
- shellmate/plugins/base.py +80 -0
- shellmate/plugins/loader.py +41 -0
- shellmate/providers/__init__.py +0 -0
- shellmate/providers/base.py +97 -0
- shellmate/providers/groq_command_guide.py +108 -0
- shellmate/providers/groq_provider.py +127 -0
- shellmate/providers/groq_supervisor.py +78 -0
- shellmate/providers/openrouter_provider.py +149 -0
- shellmate/providers/router.py +272 -0
- shellmate/providers/validate_keys.py +98 -0
- shellmate/providers/xai_provider.py +142 -0
- shellmate/rules/__init__.py +0 -0
- shellmate/rules/catastrophic.yaml +96 -0
- shellmate/rules/catastrophic_matcher.py +167 -0
- shellmate/rules/definitions/__init__.py +0 -0
- shellmate/rules/definitions/file_deletion.yaml +54 -0
- shellmate/rules/definitions/filesystem.yaml +43 -0
- shellmate/rules/definitions/network_execution.yaml +46 -0
- shellmate/rules/definitions/permissions.yaml +54 -0
- shellmate/rules/definitions/process_control.yaml +37 -0
- shellmate/rules/definitions/system_destruction.yaml +67 -0
- shellmate/rules/engine.py +200 -0
- shellmate/sanitizer/__init__.py +0 -0
- shellmate/sanitizer/typo_detector.py +114 -0
- shellmate/ui/__init__.py +10 -0
- shellmate/ui/analysis.py +164 -0
- shellmate/ui/annotations.py +232 -0
- shellmate/ui/banner.py +49 -0
- shellmate/ui/progress.py +126 -0
- shellmate/ui/runtime/__init__.py +15 -0
- shellmate/ui/runtime/console_factory.py +65 -0
- shellmate/ui/runtime/live_scene.py +116 -0
- shellmate/ui/runtime/motion_policy.py +69 -0
- shellmate/ui/runtime/tokens.py +47 -0
- shellmate/ui/scenes/__init__.py +24 -0
- shellmate/ui/scenes/enable.py +316 -0
- shellmate/ui/scenes/help.py +53 -0
- shellmate/ui/scenes/stages.py +128 -0
- shellmate/ui/scenes/status.py +181 -0
- shellmate/ui/status.py +3 -0
- shellmate_cli-3.0.0.dist-info/METADATA +352 -0
- shellmate_cli-3.0.0.dist-info/RECORD +106 -0
- shellmate_cli-3.0.0.dist-info/WHEEL +5 -0
- shellmate_cli-3.0.0.dist-info/entry_points.txt +3 -0
- shellmate_cli-3.0.0.dist-info/licenses/LICENSE +21 -0
- 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)
|
shellmate/ui/progress.py
ADDED
|
@@ -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
|