emdash-cli 0.1.46__py3-none-any.whl → 0.1.70__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 (39) hide show
  1. emdash_cli/client.py +12 -28
  2. emdash_cli/commands/__init__.py +2 -2
  3. emdash_cli/commands/agent/constants.py +78 -0
  4. emdash_cli/commands/agent/handlers/__init__.py +10 -0
  5. emdash_cli/commands/agent/handlers/agents.py +67 -39
  6. emdash_cli/commands/agent/handlers/index.py +183 -0
  7. emdash_cli/commands/agent/handlers/misc.py +119 -0
  8. emdash_cli/commands/agent/handlers/registry.py +72 -0
  9. emdash_cli/commands/agent/handlers/rules.py +48 -31
  10. emdash_cli/commands/agent/handlers/sessions.py +1 -1
  11. emdash_cli/commands/agent/handlers/setup.py +187 -54
  12. emdash_cli/commands/agent/handlers/skills.py +42 -4
  13. emdash_cli/commands/agent/handlers/telegram.py +523 -0
  14. emdash_cli/commands/agent/handlers/todos.py +55 -34
  15. emdash_cli/commands/agent/handlers/verify.py +10 -5
  16. emdash_cli/commands/agent/help.py +236 -0
  17. emdash_cli/commands/agent/interactive.py +278 -47
  18. emdash_cli/commands/agent/menus.py +116 -84
  19. emdash_cli/commands/agent/onboarding.py +619 -0
  20. emdash_cli/commands/agent/session_restore.py +210 -0
  21. emdash_cli/commands/index.py +111 -13
  22. emdash_cli/commands/registry.py +635 -0
  23. emdash_cli/commands/skills.py +72 -6
  24. emdash_cli/design.py +328 -0
  25. emdash_cli/diff_renderer.py +438 -0
  26. emdash_cli/integrations/__init__.py +1 -0
  27. emdash_cli/integrations/telegram/__init__.py +15 -0
  28. emdash_cli/integrations/telegram/bot.py +402 -0
  29. emdash_cli/integrations/telegram/bridge.py +980 -0
  30. emdash_cli/integrations/telegram/config.py +155 -0
  31. emdash_cli/integrations/telegram/formatter.py +392 -0
  32. emdash_cli/main.py +52 -2
  33. emdash_cli/sse_renderer.py +632 -171
  34. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/METADATA +2 -2
  35. emdash_cli-0.1.70.dist-info/RECORD +63 -0
  36. emdash_cli/commands/swarm.py +0 -86
  37. emdash_cli-0.1.46.dist-info/RECORD +0 -49
  38. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/WHEEL +0 -0
  39. {emdash_cli-0.1.46.dist-info → emdash_cli-0.1.70.dist-info}/entry_points.txt +0 -0
@@ -1,9 +1,17 @@
1
1
  """Handlers for miscellaneous slash commands."""
2
2
 
3
+ import json
4
+ import subprocess
3
5
  from datetime import datetime
4
6
  from pathlib import Path
5
7
 
6
8
  from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+ from rich.text import Text
12
+
13
+ from emdash_cli.design import Colors, EM_DASH
14
+ from emdash_cli.diff_renderer import render_diff
7
15
 
8
16
  console = Console()
9
17
 
@@ -197,4 +205,115 @@ def handle_context(renderer) -> None:
197
205
  console.print(f"\n[dim]No reranked items: {debug_info}[/dim]")
198
206
  else:
199
207
  console.print(f"\n[dim]No reranked items yet. Items appear after exploration (file reads, searches).[/dim]")
208
+
209
+ # Show full context frame as JSON
210
+ console.print(f"\n[bold]Full Context Frame:[/bold]")
211
+ context_json = json.dumps(context_data, indent=2, default=str)
212
+ syntax = Syntax(context_json, "json", theme="monokai", line_numbers=False)
213
+ console.print(syntax)
200
214
  console.print()
215
+
216
+
217
+ def handle_compact(client, session_id: str | None) -> None:
218
+ """Handle /compact command.
219
+
220
+ Manually triggers message history compaction using LLM summarization.
221
+
222
+ Args:
223
+ client: EmdashClient instance
224
+ session_id: Current session ID (if any)
225
+ """
226
+ if not session_id:
227
+ console.print("\n[yellow]No active session. Start a conversation first.[/yellow]\n")
228
+ return
229
+
230
+ console.print("\n[bold cyan]Compacting message history...[/bold cyan]\n")
231
+
232
+ try:
233
+ response = client.post(f"/api/agent/chat/{session_id}/compact")
234
+
235
+ if response.status_code == 404:
236
+ console.print("[yellow]Session not found.[/yellow]\n")
237
+ return
238
+
239
+ if response.status_code != 200:
240
+ console.print(f"[red]Error: {response.text}[/red]\n")
241
+ return
242
+
243
+ data = response.json()
244
+
245
+ if not data.get("compacted"):
246
+ reason = data.get("reason", "Unknown reason")
247
+ console.print(f"[yellow]Could not compact: {reason}[/yellow]\n")
248
+ return
249
+
250
+ # Show stats
251
+ original_msgs = data.get("original_message_count", 0)
252
+ new_msgs = data.get("new_message_count", 0)
253
+ original_tokens = data.get("original_tokens", 0)
254
+ new_tokens = data.get("new_tokens", 0)
255
+ reduction = data.get("reduction_percent", 0)
256
+
257
+ console.print("[green]✓ Compaction complete![/green]\n")
258
+ console.print(f"[bold]Messages:[/bold] {original_msgs} → {new_msgs}")
259
+ console.print(f"[bold]Tokens:[/bold] {original_tokens:,} → {new_tokens:,} ([green]-{reduction}%[/green])")
260
+
261
+ # Show the summary
262
+ summary = data.get("summary")
263
+ if summary:
264
+ console.print(f"\n[bold]Summary:[/bold]")
265
+ console.print(f"[dim]{'─' * 60}[/dim]")
266
+ console.print(summary)
267
+ console.print(f"[dim]{'─' * 60}[/dim]")
268
+
269
+ console.print()
270
+
271
+ except Exception as e:
272
+ console.print(f"[red]Error during compaction: {e}[/red]\n")
273
+
274
+
275
+ def handle_diff(args: str = "") -> None:
276
+ """Handle /diff command - show uncommitted changes in GitHub-style diff view.
277
+
278
+ Args:
279
+ args: Optional file path to show diff for specific file
280
+ """
281
+ try:
282
+ # Build git diff command
283
+ cmd = ["git", "diff", "--no-color"]
284
+ if args:
285
+ cmd.append(args)
286
+
287
+ # Also include staged changes
288
+ result_unstaged = subprocess.run(
289
+ cmd, capture_output=True, text=True, cwd=Path.cwd()
290
+ )
291
+
292
+ cmd_staged = ["git", "diff", "--staged", "--no-color"]
293
+ if args:
294
+ cmd_staged.append(args)
295
+
296
+ result_staged = subprocess.run(
297
+ cmd_staged, capture_output=True, text=True, cwd=Path.cwd()
298
+ )
299
+
300
+ # Combine diffs
301
+ diff_output = ""
302
+ if result_staged.stdout:
303
+ diff_output += result_staged.stdout
304
+ if result_unstaged.stdout:
305
+ if diff_output:
306
+ diff_output += "\n"
307
+ diff_output += result_unstaged.stdout
308
+
309
+ if not diff_output:
310
+ console.print(f"\n[{Colors.MUTED}]No uncommitted changes.[/{Colors.MUTED}]\n")
311
+ return
312
+
313
+ # Render diff with line numbers and syntax highlighting
314
+ render_diff(diff_output, console)
315
+
316
+ except FileNotFoundError:
317
+ console.print(f"\n[{Colors.ERROR}]Git not found. Make sure git is installed.[/{Colors.ERROR}]\n")
318
+ except Exception as e:
319
+ console.print(f"\n[{Colors.ERROR}]Error running git diff: {e}[/{Colors.ERROR}]\n")
@@ -0,0 +1,72 @@
1
+ """Handler for /registry command."""
2
+
3
+ from rich.console import Console
4
+
5
+ console = Console()
6
+
7
+
8
+ def handle_registry(args: str) -> None:
9
+ """Handle /registry command.
10
+
11
+ Args:
12
+ args: Command arguments (list, show, install, search)
13
+ """
14
+ from emdash_cli.commands.registry import (
15
+ _show_registry_wizard,
16
+ _fetch_registry,
17
+ registry_list,
18
+ registry_show,
19
+ registry_install,
20
+ registry_search,
21
+ )
22
+
23
+ # Parse subcommand
24
+ subparts = args.split(maxsplit=1) if args else []
25
+ subcommand = subparts[0].lower() if subparts else ""
26
+ subargs = subparts[1] if len(subparts) > 1 else ""
27
+
28
+ if subcommand == "" or subcommand == "wizard":
29
+ # Show interactive wizard (default)
30
+ _show_registry_wizard()
31
+
32
+ elif subcommand == "list":
33
+ # List components
34
+ component_type = subargs if subargs else None
35
+ if component_type and component_type not in ["skills", "rules", "agents", "verifiers"]:
36
+ console.print(f"[yellow]Unknown type: {component_type}[/yellow]")
37
+ console.print("[dim]Types: skills, rules, agents, verifiers[/dim]")
38
+ return
39
+ # Invoke click command
40
+ registry_list.callback(component_type)
41
+
42
+ elif subcommand == "show":
43
+ if not subargs:
44
+ console.print("[yellow]Usage: /registry show type:name[/yellow]")
45
+ console.print("[dim]Example: /registry show skill:frontend-design[/dim]")
46
+ return
47
+ registry_show.callback(subargs)
48
+
49
+ elif subcommand == "install":
50
+ if not subargs:
51
+ console.print("[yellow]Usage: /registry install type:name [type:name ...][/yellow]")
52
+ console.print("[dim]Example: /registry install skill:frontend-design rule:typescript[/dim]")
53
+ return
54
+ component_ids = tuple(subargs.split())
55
+ registry_install.callback(component_ids)
56
+
57
+ elif subcommand == "search":
58
+ if not subargs:
59
+ console.print("[yellow]Usage: /registry search query[/yellow]")
60
+ console.print("[dim]Example: /registry search frontend[/dim]")
61
+ return
62
+ # Simple search without tag filtering from slash command
63
+ registry_search.callback(subargs, ())
64
+
65
+ else:
66
+ console.print(f"[yellow]Unknown subcommand: {subcommand}[/yellow]")
67
+ console.print("[dim]Usage: /registry [list|show|install|search][/dim]")
68
+ console.print("[dim] /registry - Interactive wizard[/dim]")
69
+ console.print("[dim] /registry list - List all components[/dim]")
70
+ console.print("[dim] /registry show x:y - Show component details[/dim]")
71
+ console.print("[dim] /registry install x:y - Install components[/dim]")
72
+ console.print("[dim] /registry search q - Search registry[/dim]")
@@ -5,6 +5,15 @@ from pathlib import Path
5
5
  from rich.console import Console
6
6
  from rich.panel import Panel
7
7
 
8
+ from ....design import (
9
+ Colors,
10
+ header,
11
+ footer,
12
+ SEPARATOR_WIDTH,
13
+ STATUS_ACTIVE,
14
+ ARROW_PROMPT,
15
+ )
16
+
8
17
  console = Console()
9
18
 
10
19
 
@@ -126,41 +135,41 @@ def show_rules_interactive_menu() -> tuple[str, str]:
126
135
  event.app.exit()
127
136
 
128
137
  def get_formatted_menu():
129
- lines = [("class:title", "Rules\n\n")]
138
+ lines = [("class:title", f"─── Rules {'─' * 35}\n\n")]
130
139
 
131
140
  if not rules:
132
- lines.append(("class:dim", "No rules defined yet.\n\n"))
141
+ lines.append(("class:dim", " No rules defined yet.\n\n"))
133
142
 
134
143
  for i, (name, preview, is_action) in enumerate(menu_items):
135
144
  is_selected = i == selected_index[0]
136
- prefix = " " if is_selected else " "
145
+ prefix = " " if is_selected else " "
137
146
 
138
147
  if is_action:
139
148
  if is_selected:
140
- lines.append(("class:action-selected", f"{prefix}{name}\n"))
149
+ lines.append(("class:action-selected", f" {prefix}{name}\n"))
141
150
  else:
142
- lines.append(("class:action", f"{prefix}{name}\n"))
151
+ lines.append(("class:action", f" {prefix}{name}\n"))
143
152
  else:
144
153
  if is_selected:
145
- lines.append(("class:rule-selected", f"{prefix}{name}"))
146
- lines.append(("class:preview-selected", f" - {preview}\n"))
154
+ lines.append(("class:rule-selected", f" {prefix}{name}"))
155
+ lines.append(("class:preview-selected", f" {preview}\n"))
147
156
  else:
148
- lines.append(("class:rule", f"{prefix}{name}"))
149
- lines.append(("class:preview", f" - {preview}\n"))
157
+ lines.append(("class:rule", f" {prefix}{name}"))
158
+ lines.append(("class:preview", f" {preview}\n"))
150
159
 
151
- lines.append(("class:hint", "\n↑/↓ navigate Enter viewn newd deleteq quit"))
160
+ lines.append(("class:hint", f"\n{'─' * 45}\n ↑↓ navigate Enter view n new d delete q quit"))
152
161
  return lines
153
162
 
154
163
  style = Style.from_dict({
155
- "title": "#00ccff bold",
156
- "dim": "#666666",
157
- "rule": "#00ccff",
158
- "rule-selected": "#00cc66 bold",
159
- "action": "#ffcc00",
160
- "action-selected": "#ffcc00 bold",
161
- "preview": "#666666",
162
- "preview-selected": "#00cc66",
163
- "hint": "#888888 italic",
164
+ "title": f"{Colors.MUTED}",
165
+ "dim": f"{Colors.DIM}",
166
+ "rule": f"{Colors.PRIMARY}",
167
+ "rule-selected": f"{Colors.SUCCESS} bold",
168
+ "action": f"{Colors.WARNING}",
169
+ "action-selected": f"{Colors.WARNING} bold",
170
+ "preview": f"{Colors.DIM}",
171
+ "preview-selected": f"{Colors.SUCCESS}",
172
+ "hint": f"{Colors.DIM}",
164
173
  })
165
174
 
166
175
  height = len(menu_items) + 5 # items + title + hint + padding
@@ -200,22 +209,29 @@ def show_rule_details(name: str) -> None:
200
209
  rule_file = rules_dir / f"{name}.md"
201
210
 
202
211
  console.print()
203
- console.print("[dim]─" * 50 + "[/dim]")
212
+ console.print(f"[{Colors.MUTED}]{header(name, SEPARATOR_WIDTH)}[/{Colors.MUTED}]")
204
213
  console.print()
205
214
 
206
215
  if rule_file.exists():
207
216
  try:
208
217
  content = rule_file.read_text()
209
- console.print(f"[bold cyan]{name}[/bold cyan]\n")
210
- console.print(f"[bold]File:[/bold] {rule_file}\n")
211
- console.print(Panel(content, border_style="dim"))
218
+ console.print(f" [{Colors.DIM}]file[/{Colors.DIM}] {rule_file}")
219
+ console.print()
220
+
221
+ # Show content with indentation
222
+ for line in content.split('\n'):
223
+ if line.startswith('#'):
224
+ console.print(f" [{Colors.PRIMARY}]{line}[/{Colors.PRIMARY}]")
225
+ else:
226
+ console.print(f" [{Colors.MUTED}]{line}[/{Colors.MUTED}]")
227
+
212
228
  except Exception as e:
213
- console.print(f"[red]Error reading rule: {e}[/red]")
229
+ console.print(f" [{Colors.ERROR}]Error reading rule: {e}[/{Colors.ERROR}]")
214
230
  else:
215
- console.print(f"[yellow]Rule '{name}' not found[/yellow]")
231
+ console.print(f" [{Colors.WARNING}]Rule '{name}' not found[/{Colors.WARNING}]")
216
232
 
217
233
  console.print()
218
- console.print("[dim]─" * 50 + "[/dim]")
234
+ console.print(f"[{Colors.MUTED}]{footer(SEPARATOR_WIDTH)}[/{Colors.MUTED}]")
219
235
 
220
236
 
221
237
  def confirm_delete(rule_name: str) -> bool:
@@ -260,13 +276,14 @@ def chat_create_rule(client, renderer, model, max_iterations, render_with_interr
260
276
  rules_dir = get_rules_dir()
261
277
 
262
278
  console.print()
263
- console.print("[bold cyan]Create New Rule[/bold cyan]")
264
- console.print("[dim]Describe what rule you want to create. The AI will help you write it.[/dim]")
265
- console.print("[dim]Type 'done' to finish, Ctrl+C to cancel[/dim]")
279
+ console.print(f"[{Colors.MUTED}]{header('Create Rule', SEPARATOR_WIDTH)}[/{Colors.MUTED}]")
280
+ console.print()
281
+ console.print(f" [{Colors.DIM}]Describe your rule. AI will help write it.[/{Colors.DIM}]")
282
+ console.print(f" [{Colors.DIM}]Type 'done' to finish.[/{Colors.DIM}]")
266
283
  console.print()
267
284
 
268
285
  chat_style = Style.from_dict({
269
- "prompt": "#00cc66 bold",
286
+ "prompt": f"{Colors.PRIMARY} bold",
270
287
  })
271
288
 
272
289
  ps = PromptSession(style=chat_style)
@@ -379,7 +396,7 @@ def handle_rules(args: str, client, renderer, model, max_iterations, render_with
379
396
  show_rule_details(rule_name)
380
397
  # After viewing, show options
381
398
  try:
382
- console.print("[dim]'d' delete • Enter back[/dim]", end="")
399
+ console.print("[red]'d'[/red] delete • [dim]Enter back[/dim]", end="")
383
400
  ps = PromptSession()
384
401
  resp = ps.prompt(" ").strip().lower()
385
402
  if resp == 'd':
@@ -30,7 +30,7 @@ def handle_session(
30
30
  current_mode_ref: Reference to current_mode (list wrapper for mutation)
31
31
  loaded_messages_ref: Reference to loaded_messages (list wrapper for mutation)
32
32
  """
33
- from ...session_store import SessionStore
33
+ from ....session_store import SessionStore
34
34
 
35
35
  store = SessionStore(Path.cwd())
36
36
 
@@ -100,81 +100,214 @@ When this skill is invoked, you should:
100
100
 
101
101
  def show_setup_menu() -> SetupMode | None:
102
102
  """Show the main setup menu and return selected mode."""
103
- console.print()
104
- console.print(Panel(
105
- "[bold cyan]Emdash Setup Wizard[/bold cyan]\n\n"
106
- "Configure your project's rules, agents, skills, and verifiers.",
107
- border_style="cyan",
108
- ))
109
- console.print()
103
+ from prompt_toolkit import Application
104
+ from prompt_toolkit.key_binding import KeyBindings
105
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
106
+ from prompt_toolkit.styles import Style
110
107
 
111
108
  options = [
112
109
  (SetupMode.RULES, "Rules", "Define coding standards and guidelines for the agent"),
113
110
  (SetupMode.AGENTS, "Agents", "Create custom agents with specialized prompts"),
114
111
  (SetupMode.SKILLS, "Skills", "Add reusable skills invokable via slash commands"),
115
112
  (SetupMode.VERIFIERS, "Verifiers", "Set up verification checks for your work"),
113
+ (None, "Quit", "Exit setup wizard"),
116
114
  ]
117
115
 
118
- for i, (mode, name, desc) in enumerate(options, 1):
119
- console.print(f" [cyan]{i}[/cyan]. [bold]{name}[/bold]")
120
- console.print(f" [dim]{desc}[/dim]")
121
- console.print()
116
+ selected_index = [0]
117
+ result = [None]
118
+
119
+ kb = KeyBindings()
120
+
121
+ @kb.add("up")
122
+ @kb.add("k")
123
+ def move_up(event):
124
+ selected_index[0] = (selected_index[0] - 1) % len(options)
125
+
126
+ @kb.add("down")
127
+ @kb.add("j")
128
+ def move_down(event):
129
+ selected_index[0] = (selected_index[0] + 1) % len(options)
130
+
131
+ @kb.add("enter")
132
+ def select(event):
133
+ result[0] = options[selected_index[0]][0]
134
+ event.app.exit()
135
+
136
+ @kb.add("c-c")
137
+ @kb.add("escape")
138
+ @kb.add("q")
139
+ def cancel(event):
140
+ result[0] = None
141
+ event.app.exit()
142
+
143
+ def get_formatted_menu():
144
+ lines = [
145
+ ("class:title", "Emdash Setup Wizard\n"),
146
+ ("class:subtitle", "Configure your project's rules, agents, skills, and verifiers.\n\n"),
147
+ ]
148
+
149
+ for i, (mode, name, desc) in enumerate(options):
150
+ is_selected = i == selected_index[0]
151
+ prefix = "❯ " if is_selected else " "
152
+
153
+ if mode is None: # Quit option
154
+ if is_selected:
155
+ lines.append(("class:quit-selected", f"{prefix}{name}\n"))
156
+ else:
157
+ lines.append(("class:quit", f"{prefix}{name}\n"))
158
+ else:
159
+ if is_selected:
160
+ lines.append(("class:item-selected", f"{prefix}{name}"))
161
+ lines.append(("class:desc-selected", f" {desc}\n"))
162
+ else:
163
+ lines.append(("class:item", f"{prefix}{name}"))
164
+ lines.append(("class:desc", f" {desc}\n"))
165
+
166
+ lines.append(("class:hint", "\n↑/↓ navigate • Enter select • q quit"))
167
+ return lines
168
+
169
+ style = Style.from_dict({
170
+ "title": "#00ccff bold",
171
+ "subtitle": "#888888",
172
+ "item": "#00ccff",
173
+ "item-selected": "#00cc66 bold",
174
+ "desc": "#666666",
175
+ "desc-selected": "#00cc66",
176
+ "quit": "#888888",
177
+ "quit-selected": "#ff6666 bold",
178
+ "hint": "#888888 italic",
179
+ })
180
+
181
+ layout = Layout(
182
+ HSplit([
183
+ Window(
184
+ FormattedTextControl(get_formatted_menu),
185
+ height=len(options) + 5,
186
+ ),
187
+ ])
188
+ )
189
+
190
+ app = Application(
191
+ layout=layout,
192
+ key_bindings=kb,
193
+ style=style,
194
+ full_screen=False,
195
+ )
122
196
 
123
- console.print(" [dim]q[/dim]. Quit setup wizard")
124
197
  console.print()
125
198
 
126
199
  try:
127
- ps = PromptSession()
128
- choice = ps.prompt("Select [1-4, q]: ").strip().lower()
129
-
130
- if choice == 'q' or choice == 'quit':
131
- return None
132
- elif choice == '1':
133
- return SetupMode.RULES
134
- elif choice == '2':
135
- return SetupMode.AGENTS
136
- elif choice == '3':
137
- return SetupMode.SKILLS
138
- elif choice == '4':
139
- return SetupMode.VERIFIERS
140
- else:
141
- console.print("[yellow]Invalid choice[/yellow]")
142
- return show_setup_menu()
200
+ app.run()
143
201
  except (KeyboardInterrupt, EOFError):
144
- return None
202
+ result[0] = None
203
+
204
+ console.print()
205
+ return result[0]
145
206
 
146
207
 
147
208
  def show_action_menu(mode: SetupMode) -> str | None:
148
209
  """Show action menu for a mode (add/edit/list/delete)."""
149
- console.print()
150
- console.print(f"[bold cyan]{mode.value.title()} Configuration[/bold cyan]")
151
- console.print()
152
- console.print(" [cyan]1[/cyan]. Add new")
153
- console.print(" [cyan]2[/cyan]. Edit existing")
154
- console.print(" [cyan]3[/cyan]. List all")
155
- console.print(" [cyan]4[/cyan]. Delete")
156
- console.print(" [dim]b[/dim]. Back to main menu")
210
+ from prompt_toolkit import Application
211
+ from prompt_toolkit.key_binding import KeyBindings
212
+ from prompt_toolkit.layout import Layout, HSplit, Window, FormattedTextControl
213
+ from prompt_toolkit.styles import Style
214
+
215
+ options = [
216
+ ("add", "Add new", f"Create a new {mode.value[:-1]}"),
217
+ ("edit", "Edit existing", f"Modify an existing {mode.value[:-1]}"),
218
+ ("list", "List all", f"Show all configured {mode.value}"),
219
+ ("delete", "Delete", f"Remove a {mode.value[:-1]}"),
220
+ ("back", "Back", "Return to main menu"),
221
+ ]
222
+
223
+ selected_index = [0]
224
+ result = [None]
225
+
226
+ kb = KeyBindings()
227
+
228
+ @kb.add("up")
229
+ @kb.add("k")
230
+ def move_up(event):
231
+ selected_index[0] = (selected_index[0] - 1) % len(options)
232
+
233
+ @kb.add("down")
234
+ @kb.add("j")
235
+ def move_down(event):
236
+ selected_index[0] = (selected_index[0] + 1) % len(options)
237
+
238
+ @kb.add("enter")
239
+ def select(event):
240
+ result[0] = options[selected_index[0]][0]
241
+ event.app.exit()
242
+
243
+ @kb.add("c-c")
244
+ @kb.add("escape")
245
+ @kb.add("b")
246
+ def go_back(event):
247
+ result[0] = "back"
248
+ event.app.exit()
249
+
250
+ def get_formatted_menu():
251
+ lines = [
252
+ ("class:title", f"{mode.value.title()} Configuration\n\n"),
253
+ ]
254
+
255
+ for i, (action, name, desc) in enumerate(options):
256
+ is_selected = i == selected_index[0]
257
+ prefix = "❯ " if is_selected else " "
258
+
259
+ if action == "back":
260
+ if is_selected:
261
+ lines.append(("class:back-selected", f"{prefix}{name}\n"))
262
+ else:
263
+ lines.append(("class:back", f"{prefix}{name}\n"))
264
+ else:
265
+ if is_selected:
266
+ lines.append(("class:item-selected", f"{prefix}{name}"))
267
+ lines.append(("class:desc-selected", f" {desc}\n"))
268
+ else:
269
+ lines.append(("class:item", f"{prefix}{name}"))
270
+ lines.append(("class:desc", f" {desc}\n"))
271
+
272
+ lines.append(("class:hint", "\n↑/↓ navigate • Enter select • b back"))
273
+ return lines
274
+
275
+ style = Style.from_dict({
276
+ "title": "#00ccff bold",
277
+ "item": "#00ccff",
278
+ "item-selected": "#00cc66 bold",
279
+ "desc": "#666666",
280
+ "desc-selected": "#00cc66",
281
+ "back": "#888888",
282
+ "back-selected": "#ffcc00 bold",
283
+ "hint": "#888888 italic",
284
+ })
285
+
286
+ layout = Layout(
287
+ HSplit([
288
+ Window(
289
+ FormattedTextControl(get_formatted_menu),
290
+ height=len(options) + 4,
291
+ ),
292
+ ])
293
+ )
294
+
295
+ app = Application(
296
+ layout=layout,
297
+ key_bindings=kb,
298
+ style=style,
299
+ full_screen=False,
300
+ )
301
+
157
302
  console.print()
158
303
 
159
304
  try:
160
- ps = PromptSession()
161
- choice = ps.prompt("Select [1-4, b]: ").strip().lower()
162
-
163
- if choice == 'b' or choice == 'back':
164
- return 'back'
165
- elif choice == '1':
166
- return 'add'
167
- elif choice == '2':
168
- return 'edit'
169
- elif choice == '3':
170
- return 'list'
171
- elif choice == '4':
172
- return 'delete'
173
- else:
174
- console.print("[yellow]Invalid choice[/yellow]")
175
- return show_action_menu(mode)
305
+ app.run()
176
306
  except (KeyboardInterrupt, EOFError):
177
- return None
307
+ result[0] = "back"
308
+
309
+ console.print()
310
+ return result[0]
178
311
 
179
312
 
180
313
  def get_existing_items(mode: SetupMode) -> list[str]: