emdash-cli 0.1.35__py3-none-any.whl → 0.1.67__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.
- emdash_cli/client.py +41 -22
- emdash_cli/clipboard.py +30 -61
- emdash_cli/commands/__init__.py +2 -2
- emdash_cli/commands/agent/__init__.py +14 -0
- emdash_cli/commands/agent/cli.py +100 -0
- emdash_cli/commands/agent/constants.py +63 -0
- emdash_cli/commands/agent/file_utils.py +178 -0
- emdash_cli/commands/agent/handlers/__init__.py +51 -0
- emdash_cli/commands/agent/handlers/agents.py +449 -0
- emdash_cli/commands/agent/handlers/auth.py +69 -0
- emdash_cli/commands/agent/handlers/doctor.py +319 -0
- emdash_cli/commands/agent/handlers/hooks.py +121 -0
- emdash_cli/commands/agent/handlers/index.py +183 -0
- emdash_cli/commands/agent/handlers/mcp.py +183 -0
- emdash_cli/commands/agent/handlers/misc.py +319 -0
- emdash_cli/commands/agent/handlers/registry.py +72 -0
- emdash_cli/commands/agent/handlers/rules.py +411 -0
- emdash_cli/commands/agent/handlers/sessions.py +168 -0
- emdash_cli/commands/agent/handlers/setup.py +715 -0
- emdash_cli/commands/agent/handlers/skills.py +478 -0
- emdash_cli/commands/agent/handlers/telegram.py +475 -0
- emdash_cli/commands/agent/handlers/todos.py +119 -0
- emdash_cli/commands/agent/handlers/verify.py +653 -0
- emdash_cli/commands/agent/help.py +236 -0
- emdash_cli/commands/agent/interactive.py +842 -0
- emdash_cli/commands/agent/menus.py +760 -0
- emdash_cli/commands/agent/onboarding.py +619 -0
- emdash_cli/commands/agent/session_restore.py +210 -0
- emdash_cli/commands/agent.py +7 -1321
- emdash_cli/commands/index.py +111 -13
- emdash_cli/commands/registry.py +635 -0
- emdash_cli/commands/server.py +99 -40
- emdash_cli/commands/skills.py +72 -6
- emdash_cli/design.py +328 -0
- emdash_cli/diff_renderer.py +438 -0
- emdash_cli/integrations/__init__.py +1 -0
- emdash_cli/integrations/telegram/__init__.py +15 -0
- emdash_cli/integrations/telegram/bot.py +402 -0
- emdash_cli/integrations/telegram/bridge.py +865 -0
- emdash_cli/integrations/telegram/config.py +155 -0
- emdash_cli/integrations/telegram/formatter.py +385 -0
- emdash_cli/main.py +52 -2
- emdash_cli/server_manager.py +70 -10
- emdash_cli/sse_renderer.py +659 -167
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/METADATA +2 -4
- emdash_cli-0.1.67.dist-info/RECORD +63 -0
- emdash_cli/commands/swarm.py +0 -86
- emdash_cli-0.1.35.dist-info/RECORD +0 -30
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/WHEEL +0 -0
- {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/entry_points.txt +0 -0
emdash_cli/commands/server.py
CHANGED
|
@@ -10,6 +10,9 @@ from rich.console import Console
|
|
|
10
10
|
|
|
11
11
|
console = Console()
|
|
12
12
|
|
|
13
|
+
# Per-repo servers directory
|
|
14
|
+
SERVERS_DIR = Path.home() / ".emdash" / "servers"
|
|
15
|
+
|
|
13
16
|
|
|
14
17
|
@click.group()
|
|
15
18
|
def server():
|
|
@@ -26,23 +29,41 @@ def server_killall():
|
|
|
26
29
|
"""
|
|
27
30
|
killed = 0
|
|
28
31
|
|
|
29
|
-
# Kill by PID
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
# Kill servers by PID files in servers directory
|
|
33
|
+
if SERVERS_DIR.exists():
|
|
34
|
+
for pid_file in SERVERS_DIR.glob("*.pid"):
|
|
35
|
+
try:
|
|
36
|
+
pid = int(pid_file.read_text().strip())
|
|
37
|
+
os.kill(pid, signal.SIGTERM)
|
|
38
|
+
console.print(f"[green]Killed server process {pid}[/green]")
|
|
39
|
+
killed += 1
|
|
40
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
41
|
+
pass
|
|
42
|
+
finally:
|
|
43
|
+
# Clean up all files for this server
|
|
44
|
+
hash_prefix = pid_file.stem
|
|
45
|
+
for ext in [".port", ".pid", ".repo"]:
|
|
46
|
+
server_file = SERVERS_DIR / f"{hash_prefix}{ext}"
|
|
47
|
+
if server_file.exists():
|
|
48
|
+
server_file.unlink(missing_ok=True)
|
|
49
|
+
|
|
50
|
+
# Also check legacy location
|
|
51
|
+
legacy_pid_file = Path.home() / ".emdash" / "server.pid"
|
|
52
|
+
if legacy_pid_file.exists():
|
|
32
53
|
try:
|
|
33
|
-
pid = int(
|
|
54
|
+
pid = int(legacy_pid_file.read_text().strip())
|
|
34
55
|
os.kill(pid, signal.SIGTERM)
|
|
35
|
-
console.print(f"[green]Killed server process {pid}[/green]")
|
|
56
|
+
console.print(f"[green]Killed legacy server process {pid}[/green]")
|
|
36
57
|
killed += 1
|
|
37
58
|
except (ValueError, ProcessLookupError, PermissionError):
|
|
38
59
|
pass
|
|
39
60
|
finally:
|
|
40
|
-
|
|
61
|
+
legacy_pid_file.unlink(missing_ok=True)
|
|
41
62
|
|
|
42
|
-
# Clean up port file
|
|
43
|
-
|
|
44
|
-
if
|
|
45
|
-
|
|
63
|
+
# Clean up legacy port file
|
|
64
|
+
legacy_port_file = Path.home() / ".emdash" / "server.port"
|
|
65
|
+
if legacy_port_file.exists():
|
|
66
|
+
legacy_port_file.unlink(missing_ok=True)
|
|
46
67
|
|
|
47
68
|
# Kill any remaining emdash_core.server processes
|
|
48
69
|
try:
|
|
@@ -77,41 +98,79 @@ def server_killall():
|
|
|
77
98
|
|
|
78
99
|
@server.command("status")
|
|
79
100
|
def server_status():
|
|
80
|
-
"""Show
|
|
101
|
+
"""Show status of all running servers.
|
|
81
102
|
|
|
82
103
|
Example:
|
|
83
104
|
emdash server status
|
|
84
105
|
"""
|
|
85
|
-
|
|
86
|
-
pid_file = Path.home() / ".emdash" / "server.pid"
|
|
106
|
+
import httpx
|
|
87
107
|
|
|
88
|
-
|
|
89
|
-
console.print("[yellow]No server running[/yellow]")
|
|
90
|
-
return
|
|
108
|
+
servers_found = []
|
|
91
109
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
# Check per-repo servers directory
|
|
111
|
+
if SERVERS_DIR.exists():
|
|
112
|
+
for port_file in SERVERS_DIR.glob("*.port"):
|
|
113
|
+
try:
|
|
114
|
+
port = int(port_file.read_text().strip())
|
|
115
|
+
hash_prefix = port_file.stem
|
|
97
116
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
117
|
+
# Get repo path if available
|
|
118
|
+
repo_file = SERVERS_DIR / f"{hash_prefix}.repo"
|
|
119
|
+
repo_path = repo_file.read_text().strip() if repo_file.exists() else "unknown"
|
|
120
|
+
|
|
121
|
+
# Get PID if available
|
|
122
|
+
pid_file = SERVERS_DIR / f"{hash_prefix}.pid"
|
|
123
|
+
pid = pid_file.read_text().strip() if pid_file.exists() else "unknown"
|
|
124
|
+
|
|
125
|
+
# Check health
|
|
105
126
|
try:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
127
|
+
response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
|
|
128
|
+
healthy = response.status_code == 200
|
|
129
|
+
except (httpx.RequestError, httpx.TimeoutException):
|
|
130
|
+
healthy = False
|
|
131
|
+
|
|
132
|
+
servers_found.append({
|
|
133
|
+
"port": port,
|
|
134
|
+
"pid": pid,
|
|
135
|
+
"repo": repo_path,
|
|
136
|
+
"healthy": healthy,
|
|
137
|
+
})
|
|
138
|
+
except (ValueError, IOError):
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
# Check legacy location
|
|
142
|
+
legacy_port_file = Path.home() / ".emdash" / "server.port"
|
|
143
|
+
if legacy_port_file.exists():
|
|
144
|
+
try:
|
|
145
|
+
port = int(legacy_port_file.read_text().strip())
|
|
146
|
+
legacy_pid_file = Path.home() / ".emdash" / "server.pid"
|
|
147
|
+
pid = legacy_pid_file.read_text().strip() if legacy_pid_file.exists() else "unknown"
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
|
|
151
|
+
healthy = response.status_code == 200
|
|
152
|
+
except (httpx.RequestError, httpx.TimeoutException):
|
|
153
|
+
healthy = False
|
|
154
|
+
|
|
155
|
+
servers_found.append({
|
|
156
|
+
"port": port,
|
|
157
|
+
"pid": pid,
|
|
158
|
+
"repo": "(legacy)",
|
|
159
|
+
"healthy": healthy,
|
|
160
|
+
})
|
|
161
|
+
except (ValueError, IOError):
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
if not servers_found:
|
|
165
|
+
console.print("[yellow]No servers running[/yellow]")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
console.print(f"[bold]Found {len(servers_found)} server(s):[/bold]\n")
|
|
169
|
+
for srv in servers_found:
|
|
170
|
+
status = "[green]healthy[/green]" if srv["healthy"] else "[red]unhealthy[/red]"
|
|
171
|
+
console.print(f" {status}")
|
|
172
|
+
console.print(f" Port: {srv['port']}")
|
|
173
|
+
console.print(f" PID: {srv['pid']}")
|
|
174
|
+
console.print(f" Repo: {srv['repo']}")
|
|
175
|
+
console.print(f" URL: http://localhost:{srv['port']}")
|
|
176
|
+
console.print()
|
emdash_cli/commands/skills.py
CHANGED
|
@@ -41,11 +41,13 @@ def skills_list():
|
|
|
41
41
|
table.add_column("Description")
|
|
42
42
|
table.add_column("User Invocable", style="green")
|
|
43
43
|
table.add_column("Tools")
|
|
44
|
+
table.add_column("Scripts", style="yellow")
|
|
44
45
|
|
|
45
46
|
for skill in all_skills.values():
|
|
46
47
|
invocable = "Yes (/{})".format(skill.name) if skill.user_invocable else "No"
|
|
47
48
|
tools = ", ".join(skill.tools) if skill.tools else "-"
|
|
48
|
-
|
|
49
|
+
scripts = str(len(skill.scripts)) if skill.scripts else "-"
|
|
50
|
+
table.add_row(skill.name, skill.description, invocable, tools, scripts)
|
|
49
51
|
|
|
50
52
|
console.print(table)
|
|
51
53
|
|
|
@@ -69,12 +71,28 @@ def skills_show(name: str):
|
|
|
69
71
|
console.print(f"[dim]Available skills: {', '.join(available)}[/dim]")
|
|
70
72
|
return
|
|
71
73
|
|
|
74
|
+
# Build scripts info
|
|
75
|
+
scripts_info = "None"
|
|
76
|
+
if skill.scripts:
|
|
77
|
+
scripts_info = "\n".join([f" - {s.name} ({s})" for s in skill.scripts])
|
|
78
|
+
|
|
72
79
|
# Show skill details
|
|
73
|
-
|
|
80
|
+
details = (
|
|
74
81
|
f"[bold]Description:[/bold] {skill.description}\n\n"
|
|
75
82
|
f"[bold]User Invocable:[/bold] {'Yes (/' + skill.name + ')' if skill.user_invocable else 'No'}\n\n"
|
|
76
83
|
f"[bold]Tools:[/bold] {', '.join(skill.tools) if skill.tools else 'None'}\n\n"
|
|
77
|
-
f"[bold]
|
|
84
|
+
f"[bold]Scripts:[/bold] {len(skill.scripts) if skill.scripts else 'None'}"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if skill.scripts:
|
|
88
|
+
details += "\n"
|
|
89
|
+
for script in skill.scripts:
|
|
90
|
+
details += f"\n [yellow]{script.name}[/yellow]: {script}"
|
|
91
|
+
|
|
92
|
+
details += f"\n\n[bold]File:[/bold] {skill.file_path}"
|
|
93
|
+
|
|
94
|
+
console.print(Panel(
|
|
95
|
+
details,
|
|
78
96
|
title=f"[cyan]{skill.name}[/cyan]",
|
|
79
97
|
border_style="cyan",
|
|
80
98
|
))
|
|
@@ -92,14 +110,19 @@ def skills_show(name: str):
|
|
|
92
110
|
@click.option("--description", "-d", default="", help="Skill description")
|
|
93
111
|
@click.option("--user-invocable/--no-user-invocable", default=True, help="Can be invoked with /name")
|
|
94
112
|
@click.option("--tools", "-t", multiple=True, help="Tools this skill needs (can specify multiple)")
|
|
95
|
-
|
|
113
|
+
@click.option("--with-script", "-s", is_flag=True, help="Include a sample executable script")
|
|
114
|
+
def skills_create(name: str, description: str, user_invocable: bool, tools: tuple, with_script: bool):
|
|
96
115
|
"""Create a new skill.
|
|
97
116
|
|
|
98
|
-
Creates a skill directory with SKILL.md template.
|
|
117
|
+
Creates a skill directory with SKILL.md template and optional scripts.
|
|
99
118
|
|
|
100
119
|
Example:
|
|
101
120
|
emdash skills create commit -d "Generate commit messages" -t execute_command -t read_file
|
|
121
|
+
emdash skills create deploy -d "Deploy application" --with-script
|
|
102
122
|
"""
|
|
123
|
+
import os
|
|
124
|
+
import stat
|
|
125
|
+
|
|
103
126
|
# Validate name
|
|
104
127
|
name = name.lower().strip()
|
|
105
128
|
if len(name) > 64:
|
|
@@ -122,6 +145,20 @@ def skills_create(name: str, description: str, user_invocable: bool, tools: tupl
|
|
|
122
145
|
tools_str = ", ".join(tools) if tools else ""
|
|
123
146
|
description = description or f"Description for {name} skill"
|
|
124
147
|
|
|
148
|
+
# Add script documentation if creating with script
|
|
149
|
+
script_docs = ""
|
|
150
|
+
if with_script:
|
|
151
|
+
script_docs = """
|
|
152
|
+
|
|
153
|
+
## Scripts
|
|
154
|
+
|
|
155
|
+
This skill includes executable scripts that can be run by the agent:
|
|
156
|
+
|
|
157
|
+
- `run.sh` - Main script for this skill. Execute it using: `bash <skill_dir>/run.sh`
|
|
158
|
+
|
|
159
|
+
Scripts are self-contained bash executables. The agent will run them using the Bash tool when needed.
|
|
160
|
+
"""
|
|
161
|
+
|
|
125
162
|
content = f"""---
|
|
126
163
|
name: {name}
|
|
127
164
|
description: {description}
|
|
@@ -136,7 +173,7 @@ tools: [{tools_str}]
|
|
|
136
173
|
## Instructions
|
|
137
174
|
|
|
138
175
|
Add your skill instructions here. These will be provided to the agent when the skill is invoked.
|
|
139
|
-
|
|
176
|
+
{script_docs}
|
|
140
177
|
## Usage
|
|
141
178
|
|
|
142
179
|
Describe how this skill should be used.
|
|
@@ -150,7 +187,36 @@ Provide example scenarios here.
|
|
|
150
187
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
151
188
|
skill_file.write_text(content)
|
|
152
189
|
console.print(f"[green]Created skill '{name}' at {skill_file}[/green]")
|
|
190
|
+
|
|
191
|
+
# Create sample script if requested
|
|
192
|
+
if with_script:
|
|
193
|
+
script_file = skill_dir / "run.sh"
|
|
194
|
+
script_content = f"""#!/bin/bash
|
|
195
|
+
# {name} skill script
|
|
196
|
+
# This script is executed by the agent when needed.
|
|
197
|
+
# All scripts must be self-contained and executable.
|
|
198
|
+
|
|
199
|
+
set -e
|
|
200
|
+
|
|
201
|
+
echo "Running {name} skill script..."
|
|
202
|
+
|
|
203
|
+
# Add your script logic here
|
|
204
|
+
# Example:
|
|
205
|
+
# - Check prerequisites
|
|
206
|
+
# - Execute commands
|
|
207
|
+
# - Output results
|
|
208
|
+
|
|
209
|
+
echo "Script completed successfully."
|
|
210
|
+
"""
|
|
211
|
+
script_file.write_text(script_content)
|
|
212
|
+
# Make executable
|
|
213
|
+
current_mode = script_file.stat().st_mode
|
|
214
|
+
os.chmod(script_file, current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
215
|
+
console.print(f"[green]Created script: {script_file}[/green]")
|
|
216
|
+
|
|
153
217
|
console.print(f"[dim]Edit the SKILL.md file to customize the skill instructions.[/dim]")
|
|
218
|
+
if with_script:
|
|
219
|
+
console.print(f"[dim]Edit run.sh to add your script logic.[/dim]")
|
|
154
220
|
except Exception as e:
|
|
155
221
|
console.print(f"[red]Error creating skill: {e}[/red]")
|
|
156
222
|
|
emdash_cli/design.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Emdash CLI Design System.
|
|
2
|
+
|
|
3
|
+
A zen, geometric design language combining clean lines with dot-matrix textures.
|
|
4
|
+
The em dash (─) is the signature element, appearing in all separators and frames.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
# Logo & Branding
|
|
9
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
# Large stylized logo using block characters - minimal, geometric, impactful
|
|
12
|
+
LOGO_LARGE = r"""
|
|
13
|
+
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
|
14
|
+
█ █
|
|
15
|
+
█ ▓█▀▀▀ █▀▄▀█ █▀▀▄ ▄▀▀▄ ▓█▀▀▀█ █ █ █
|
|
16
|
+
█ ▓█▀▀ █ ▀ █ █ █ █▀▀█ ▓▀▀▀▀█ █▀▀█ ───── █
|
|
17
|
+
█ ▓█▄▄▄ █ █ █▄▄▀ █ █ ▓█▄▄▄█ █ █ █
|
|
18
|
+
█ █
|
|
19
|
+
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Medium logo - cleaner, more readable
|
|
23
|
+
LOGO_MEDIUM = r"""
|
|
24
|
+
╭─────────────────────────────────────────╮
|
|
25
|
+
│ │
|
|
26
|
+
│ ███ █▄ ▄█ █▀▄ ▄▀▄ ▄▀▀ █ █ ─── │
|
|
27
|
+
│ █▄▄ █ ▀ █ █ █ █▀█ ▀▀█ █▀█ │
|
|
28
|
+
│ ███ █ █ █▄▀ █ █ ▄▄▀ █ █ │
|
|
29
|
+
│ │
|
|
30
|
+
╰─────────────────────────────────────────╯
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
# Compact logo - single line, stylized
|
|
34
|
+
LOGO_COMPACT = "─── ◈ emdash ◈ ───"
|
|
35
|
+
|
|
36
|
+
# Ultra-minimal logo with em dash signature
|
|
37
|
+
LOGO_MINIMAL = "── emdash ──"
|
|
38
|
+
|
|
39
|
+
# Braille-style logo - dot matrix aesthetic
|
|
40
|
+
LOGO_DOTS = r"""
|
|
41
|
+
⠀⣠⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣄⠀
|
|
42
|
+
⢸⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⡇
|
|
43
|
+
⢸⣿⠀⠀⣴⣦⠀⣾⡄⣴⣦⠀⣶⣤⡀⠀⣴⣦⠀⣶⣶⣶⠀⣾⠀⣾⠀⠀⠀⠀⠀⠀⠀⣿⡇
|
|
44
|
+
⢸⣿⠀⠀⣿⣿⠀⣿⣿⣿⣿⠀⣿⠙⣿⠀⣿⣿⠀⠀⣿⠀⠀⣿⣀⣿⠀⠤⠤⠤⠀⠀⠀⣿⡇
|
|
45
|
+
⢸⣿⠀⠀⠛⠛⠀⠛⠀⠀⠛⠀⠛⠛⠃⠀⠛⠙⠛⠀⠛⠛⠀⠛⠀⠛⠀⠀⠀⠀⠀⠀⠀⣿⡇
|
|
46
|
+
⠀⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠛⠀
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
# Block-style large text - bold and impactful
|
|
50
|
+
LOGO_BLOCK = r"""
|
|
51
|
+
╔═══════════════════════════════════════════════╗
|
|
52
|
+
║ ║
|
|
53
|
+
║ ▓▓▓▓ ▓ ▓ ▓▓▓ ▓▓▓ ▓▓▓▓ ▓ ▓ ║
|
|
54
|
+
║ ▓ ▓▓ ▓▓ ▓ ▓ ▓ ▓ ▓ ▓▓▓▓ ──── ║
|
|
55
|
+
║ ▓▓▓ ▓ ▓ ▓ ▓ ▓ ▓▓▓▓▓ ▓▓▓▓ ▓ ▓ ║
|
|
56
|
+
║ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ▓ ║
|
|
57
|
+
║ ▓▓▓▓ ▓ ▓ ▓▓▓ ▓ ▓ ▓▓▓▓ ▓ ▓ ║
|
|
58
|
+
║ ║
|
|
59
|
+
╚═══════════════════════════════════════════════╝
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Clean geometric logo - the recommended default
|
|
63
|
+
LOGO = r"""
|
|
64
|
+
┌─────────────────────────────────────────────┐
|
|
65
|
+
│ │
|
|
66
|
+
│ ╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ │
|
|
67
|
+
│ │
|
|
68
|
+
│ ◈ e m d a s h ◈ │
|
|
69
|
+
│ │
|
|
70
|
+
│ ╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╸ │
|
|
71
|
+
│ │
|
|
72
|
+
└─────────────────────────────────────────────┘
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
77
|
+
# Typography & Signature Elements
|
|
78
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
EM_DASH = "─"
|
|
81
|
+
SEPARATOR_WIDTH = 45
|
|
82
|
+
SEPARATOR = EM_DASH * SEPARATOR_WIDTH
|
|
83
|
+
SEPARATOR_SHORT = EM_DASH * 20
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def header(title: str, width: int = SEPARATOR_WIDTH) -> str:
|
|
87
|
+
"""Create a header with em dash separators.
|
|
88
|
+
|
|
89
|
+
Example: ─── Title ─────────────────────────────
|
|
90
|
+
"""
|
|
91
|
+
prefix = f"{EM_DASH * 3} {title} "
|
|
92
|
+
remaining = width - len(prefix)
|
|
93
|
+
return f"{prefix}{EM_DASH * max(0, remaining)}"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def footer(width: int = SEPARATOR_WIDTH) -> str:
|
|
97
|
+
"""Create a footer separator."""
|
|
98
|
+
return EM_DASH * width
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
# Dot Matrix / Stippled Elements
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
# Spinner frames - dot matrix style, feels computational
|
|
106
|
+
SPINNER_FRAMES = ["⠿", "⠷", "⠯", "⠟", "⠻", "⠽", "⠾", "⠿"]
|
|
107
|
+
|
|
108
|
+
# Stippled elements
|
|
109
|
+
DOT_ACTIVE = "⠿" # Dense braille - active/processing
|
|
110
|
+
DOT_WAITING = "∷" # Stippled - waiting/idle
|
|
111
|
+
DOT_BULLET = "∷" # List bullets
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
115
|
+
# Status Indicators
|
|
116
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
STATUS_ACTIVE = "▸" # Active/selected/success
|
|
119
|
+
STATUS_INACTIVE = "▹" # Inactive/pending
|
|
120
|
+
STATUS_ERROR = "■" # Solid - errors
|
|
121
|
+
STATUS_INFO = "□" # Outline - info
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
# Flow & Navigation
|
|
126
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
ARROW_PROMPT = "›" # Prompt/hint indicator
|
|
129
|
+
ARROW_RIGHT = "»" # Direction/flow
|
|
130
|
+
NEST_LINE = "│" # Vertical nesting
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
# Progress Elements
|
|
135
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
PROGRESS_FULL = "█"
|
|
138
|
+
PROGRESS_PARTIAL = "▓"
|
|
139
|
+
PROGRESS_EMPTY = "░"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def progress_bar(percent: float, width: int = 20) -> str:
|
|
143
|
+
"""Create a progress bar.
|
|
144
|
+
|
|
145
|
+
Example: ████████▓░░░░░░░░░░░ 42%
|
|
146
|
+
"""
|
|
147
|
+
filled = int(width * percent / 100)
|
|
148
|
+
partial = 1 if (percent % (100 / width)) > (50 / width) and filled < width else 0
|
|
149
|
+
empty = width - filled - partial
|
|
150
|
+
|
|
151
|
+
bar = PROGRESS_FULL * filled + PROGRESS_PARTIAL * partial + PROGRESS_EMPTY * empty
|
|
152
|
+
return f"{bar} {percent:.0f}%"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def step_progress(current: int, total: int, width: int = 20) -> str:
|
|
156
|
+
"""Create a step progress indicator.
|
|
157
|
+
|
|
158
|
+
Example: ∷∷∷∷∷∷∷∷∷∷∷∷∷∷∷∷∷∷∷ step 2 of 4
|
|
159
|
+
"""
|
|
160
|
+
dots = DOT_WAITING * width
|
|
161
|
+
return f"{dots} step {current} of {total}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
165
|
+
# Zen Color Palette - Red/Warm Tones
|
|
166
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
class Colors:
|
|
169
|
+
"""Zen color palette - warm, vibrant, red-accented."""
|
|
170
|
+
|
|
171
|
+
# Core semantic colors - warmer, more vibrant
|
|
172
|
+
PRIMARY = "#f0a0a0" # warm rose - main accent
|
|
173
|
+
SECONDARY = "#e8d0a0" # warm sand - secondary
|
|
174
|
+
SUCCESS = "#b8d8a8" # fresh sage - success
|
|
175
|
+
WARNING = "#f0c878" # warm amber - warnings
|
|
176
|
+
ERROR = "#e87878" # vibrant coral - errors
|
|
177
|
+
|
|
178
|
+
# Text hierarchy
|
|
179
|
+
TEXT = "#f0f0f0" # bright white
|
|
180
|
+
MUTED = "#c0a8a8" # dusty rose - secondary text
|
|
181
|
+
DIM = "#a09090" # warm gray - hints
|
|
182
|
+
|
|
183
|
+
# Accents
|
|
184
|
+
ACCENT = "#d0b0c0" # mauve - highlights
|
|
185
|
+
SUBTLE = "#b8a8a8" # warm fog - disabled
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# Rich markup shortcuts
|
|
189
|
+
class Markup:
|
|
190
|
+
"""Rich markup helpers for consistent styling."""
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def primary(text: str) -> str:
|
|
194
|
+
return f"[{Colors.PRIMARY}]{text}[/{Colors.PRIMARY}]"
|
|
195
|
+
|
|
196
|
+
@staticmethod
|
|
197
|
+
def success(text: str) -> str:
|
|
198
|
+
return f"[{Colors.SUCCESS}]{text}[/{Colors.SUCCESS}]"
|
|
199
|
+
|
|
200
|
+
@staticmethod
|
|
201
|
+
def warning(text: str) -> str:
|
|
202
|
+
return f"[{Colors.WARNING}]{text}[/{Colors.WARNING}]"
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def error(text: str) -> str:
|
|
206
|
+
return f"[{Colors.ERROR}]{text}[/{Colors.ERROR}]"
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def muted(text: str) -> str:
|
|
210
|
+
return f"[{Colors.MUTED}]{text}[/{Colors.MUTED}]"
|
|
211
|
+
|
|
212
|
+
@staticmethod
|
|
213
|
+
def dim(text: str) -> str:
|
|
214
|
+
return f"[{Colors.DIM}]{text}[/{Colors.DIM}]"
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def accent(text: str) -> str:
|
|
218
|
+
return f"[{Colors.ACCENT}]{text}[/{Colors.ACCENT}]"
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def bold(text: str) -> str:
|
|
222
|
+
return f"[bold]{text}[/bold]"
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def italic(text: str) -> str:
|
|
226
|
+
return f"[italic]{text}[/italic]"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
230
|
+
# Prompt Toolkit Styles
|
|
231
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
# Style dict for prompt_toolkit
|
|
234
|
+
PROMPT_TOOLKIT_STYLE = {
|
|
235
|
+
# Menu items
|
|
236
|
+
"selected": f"{Colors.SUCCESS} bold",
|
|
237
|
+
"option": Colors.MUTED,
|
|
238
|
+
"option-desc": Colors.DIM,
|
|
239
|
+
"hint": f"{Colors.DIM} italic",
|
|
240
|
+
|
|
241
|
+
# Headers and frames
|
|
242
|
+
"header": f"{Colors.PRIMARY} bold",
|
|
243
|
+
"separator": Colors.DIM,
|
|
244
|
+
|
|
245
|
+
# Status
|
|
246
|
+
"success": Colors.SUCCESS,
|
|
247
|
+
"warning": Colors.WARNING,
|
|
248
|
+
"error": Colors.ERROR,
|
|
249
|
+
|
|
250
|
+
# Input
|
|
251
|
+
"prompt": f"{Colors.SUCCESS} bold",
|
|
252
|
+
"prompt-plan": f"{Colors.WARNING} bold",
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
257
|
+
# ANSI Escape Codes (for raw terminal output)
|
|
258
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
class ANSI:
|
|
261
|
+
"""ANSI escape codes for direct terminal output."""
|
|
262
|
+
|
|
263
|
+
RESET = "\033[0m"
|
|
264
|
+
BOLD = "\033[1m"
|
|
265
|
+
DIM = "\033[2m"
|
|
266
|
+
ITALIC = "\033[3m"
|
|
267
|
+
|
|
268
|
+
# Warm zen palette as ANSI (256-color approximations)
|
|
269
|
+
PRIMARY = "\033[38;5;217m" # warm rose
|
|
270
|
+
SECONDARY = "\033[38;5;223m" # warm sand
|
|
271
|
+
SUCCESS = "\033[38;5;150m" # fresh sage
|
|
272
|
+
WARNING = "\033[38;5;221m" # warm amber
|
|
273
|
+
ERROR = "\033[38;5;203m" # vibrant coral
|
|
274
|
+
MUTED = "\033[38;5;181m" # dusty rose
|
|
275
|
+
SHADOW = "\033[38;5;138m" # warm gray
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
279
|
+
# Component Templates
|
|
280
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
def frame(title: str, content: str, width: int = SEPARATOR_WIDTH) -> str:
|
|
283
|
+
"""Create a framed content block.
|
|
284
|
+
|
|
285
|
+
Example:
|
|
286
|
+
─── Title ─────────────────────────────
|
|
287
|
+
|
|
288
|
+
Content goes here
|
|
289
|
+
|
|
290
|
+
─────────────────────────────────────────
|
|
291
|
+
"""
|
|
292
|
+
lines = [
|
|
293
|
+
header(title, width),
|
|
294
|
+
"",
|
|
295
|
+
content,
|
|
296
|
+
"",
|
|
297
|
+
footer(width),
|
|
298
|
+
]
|
|
299
|
+
return "\n".join(lines)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def menu_hint(*hints: tuple[str, str]) -> str:
|
|
303
|
+
"""Create a hint bar for menus.
|
|
304
|
+
|
|
305
|
+
Example: › y approve n feedback Esc cancel
|
|
306
|
+
"""
|
|
307
|
+
parts = [f"{key} {action}" for key, action in hints]
|
|
308
|
+
return f"{ARROW_PROMPT} {' '.join(parts)}"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def bullet_list(items: list[str], indent: int = 2) -> str:
|
|
312
|
+
"""Create a bulleted list with stippled bullets.
|
|
313
|
+
|
|
314
|
+
Example:
|
|
315
|
+
∷ First item
|
|
316
|
+
∷ Second item
|
|
317
|
+
"""
|
|
318
|
+
prefix = " " * indent + DOT_BULLET + " "
|
|
319
|
+
return "\n".join(f"{prefix}{item}" for item in items)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def nested_line(text: str, indent: int = 2) -> str:
|
|
323
|
+
"""Create a nested/indented line with vertical bar.
|
|
324
|
+
|
|
325
|
+
Example:
|
|
326
|
+
│ Nested content
|
|
327
|
+
"""
|
|
328
|
+
return f"{' ' * indent}{NEST_LINE} {text}"
|