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.
Files changed (50) hide show
  1. emdash_cli/client.py +41 -22
  2. emdash_cli/clipboard.py +30 -61
  3. emdash_cli/commands/__init__.py +2 -2
  4. emdash_cli/commands/agent/__init__.py +14 -0
  5. emdash_cli/commands/agent/cli.py +100 -0
  6. emdash_cli/commands/agent/constants.py +63 -0
  7. emdash_cli/commands/agent/file_utils.py +178 -0
  8. emdash_cli/commands/agent/handlers/__init__.py +51 -0
  9. emdash_cli/commands/agent/handlers/agents.py +449 -0
  10. emdash_cli/commands/agent/handlers/auth.py +69 -0
  11. emdash_cli/commands/agent/handlers/doctor.py +319 -0
  12. emdash_cli/commands/agent/handlers/hooks.py +121 -0
  13. emdash_cli/commands/agent/handlers/index.py +183 -0
  14. emdash_cli/commands/agent/handlers/mcp.py +183 -0
  15. emdash_cli/commands/agent/handlers/misc.py +319 -0
  16. emdash_cli/commands/agent/handlers/registry.py +72 -0
  17. emdash_cli/commands/agent/handlers/rules.py +411 -0
  18. emdash_cli/commands/agent/handlers/sessions.py +168 -0
  19. emdash_cli/commands/agent/handlers/setup.py +715 -0
  20. emdash_cli/commands/agent/handlers/skills.py +478 -0
  21. emdash_cli/commands/agent/handlers/telegram.py +475 -0
  22. emdash_cli/commands/agent/handlers/todos.py +119 -0
  23. emdash_cli/commands/agent/handlers/verify.py +653 -0
  24. emdash_cli/commands/agent/help.py +236 -0
  25. emdash_cli/commands/agent/interactive.py +842 -0
  26. emdash_cli/commands/agent/menus.py +760 -0
  27. emdash_cli/commands/agent/onboarding.py +619 -0
  28. emdash_cli/commands/agent/session_restore.py +210 -0
  29. emdash_cli/commands/agent.py +7 -1321
  30. emdash_cli/commands/index.py +111 -13
  31. emdash_cli/commands/registry.py +635 -0
  32. emdash_cli/commands/server.py +99 -40
  33. emdash_cli/commands/skills.py +72 -6
  34. emdash_cli/design.py +328 -0
  35. emdash_cli/diff_renderer.py +438 -0
  36. emdash_cli/integrations/__init__.py +1 -0
  37. emdash_cli/integrations/telegram/__init__.py +15 -0
  38. emdash_cli/integrations/telegram/bot.py +402 -0
  39. emdash_cli/integrations/telegram/bridge.py +865 -0
  40. emdash_cli/integrations/telegram/config.py +155 -0
  41. emdash_cli/integrations/telegram/formatter.py +385 -0
  42. emdash_cli/main.py +52 -2
  43. emdash_cli/server_manager.py +70 -10
  44. emdash_cli/sse_renderer.py +659 -167
  45. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/METADATA +2 -4
  46. emdash_cli-0.1.67.dist-info/RECORD +63 -0
  47. emdash_cli/commands/swarm.py +0 -86
  48. emdash_cli-0.1.35.dist-info/RECORD +0 -30
  49. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/WHEEL +0 -0
  50. {emdash_cli-0.1.35.dist-info → emdash_cli-0.1.67.dist-info}/entry_points.txt +0 -0
@@ -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 file first
30
- pid_file = Path.home() / ".emdash" / "server.pid"
31
- if pid_file.exists():
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(pid_file.read_text().strip())
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
- pid_file.unlink(missing_ok=True)
61
+ legacy_pid_file.unlink(missing_ok=True)
41
62
 
42
- # Clean up port file
43
- port_file = Path.home() / ".emdash" / "server.port"
44
- if port_file.exists():
45
- port_file.unlink(missing_ok=True)
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 server status.
101
+ """Show status of all running servers.
81
102
 
82
103
  Example:
83
104
  emdash server status
84
105
  """
85
- port_file = Path.home() / ".emdash" / "server.port"
86
- pid_file = Path.home() / ".emdash" / "server.pid"
106
+ import httpx
87
107
 
88
- if not port_file.exists():
89
- console.print("[yellow]No server running[/yellow]")
90
- return
108
+ servers_found = []
91
109
 
92
- try:
93
- port = int(port_file.read_text().strip())
94
- except (ValueError, IOError):
95
- console.print("[yellow]No server running (invalid port file)[/yellow]")
96
- return
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
- # Check if server is responsive
99
- import httpx
100
- try:
101
- response = httpx.get(f"http://localhost:{port}/api/health", timeout=2.0)
102
- if response.status_code == 200:
103
- pid = "unknown"
104
- if pid_file.exists():
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
- pid = pid_file.read_text().strip()
107
- except IOError:
108
- pass
109
-
110
- console.print(f"[bold green]Server running[/bold green]")
111
- console.print(f" Port: {port}")
112
- console.print(f" PID: {pid}")
113
- console.print(f" URL: http://localhost:{port}")
114
- else:
115
- console.print(f"[yellow]Server on port {port} not healthy[/yellow]")
116
- except (httpx.RequestError, httpx.TimeoutException):
117
- console.print(f"[yellow]Server on port {port} not responding[/yellow]")
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()
@@ -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
- table.add_row(skill.name, skill.description, invocable, tools)
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
- console.print(Panel(
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]File:[/bold] {skill.file_path}",
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
- def skills_create(name: str, description: str, user_invocable: bool, tools: tuple):
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}"