pulse-coding-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/cli_ui.py ADDED
@@ -0,0 +1,977 @@
1
+ """Premium Rich terminal UI for Pulse.
2
+
3
+ All original function signatures are preserved.
4
+ New exports: print_banner, print_auth_prompt, print_signed_in,
5
+ print_help_screen, print_session_footer, thinking_spinner,
6
+ print_provider_selection, print_model_selection, print_provider_changed_card,
7
+ print_current_model_card, print_all_models_list,
8
+ print_chat_list, print_chat_card, print_chat_created, print_chat_switched,
9
+ print_chat_exported, print_chat_search_results.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import itertools
14
+ import subprocess
15
+ import threading
16
+ from collections.abc import Generator
17
+ from contextlib import contextmanager
18
+ from typing import Any
19
+
20
+ from rich import box
21
+ from rich.align import Align
22
+ from rich.console import Console
23
+ from rich.markup import escape
24
+ from rich.panel import Panel
25
+ from rich.rule import Rule
26
+ from rich.table import Table
27
+
28
+ from pulse import __version__
29
+ from pulse.subprocesses import isolated_process_kwargs
30
+
31
+ # ── Global console ────────────────────────────────────────────────────────────
32
+ console = Console()
33
+
34
+ _VERSION = __version__
35
+
36
+
37
+ # ── Terminal capability helpers ───────────────────────────────────────────────
38
+
39
+ def _is_dumb() -> bool:
40
+ """Return True when stdout is a pipe, file, or dumb terminal."""
41
+ return not console.is_terminal or getattr(console, "is_dumb_terminal", False)
42
+
43
+
44
+ def _ok() -> str:
45
+ return "[OK]" if _is_dumb() else "\u2713" # ✓
46
+
47
+
48
+ def _err() -> str:
49
+ return "[ERR]" if _is_dumb() else "\u2717" # ✗
50
+
51
+
52
+ def _warn() -> str:
53
+ return "[WARN]" if _is_dumb() else "\u26a0" # ⚠
54
+
55
+
56
+ def _box_style() -> box.Box:
57
+ return box.ASCII if _is_dumb() else box.ROUNDED
58
+
59
+
60
+ # ── Core helpers ──────────────────────────────────────────────────────────────
61
+
62
+ def _panel(title: str | None, content: Any, style: str = "") -> Panel:
63
+ """Adaptive panel — ROUNDED on rich terminals, ASCII on dumb ones."""
64
+ return Panel(
65
+ content,
66
+ title=title,
67
+ style=style,
68
+ box=_box_style(),
69
+ padding=(0, 1),
70
+ )
71
+
72
+
73
+ def _rule(title: str | None = None) -> Rule:
74
+ chars = "-" if _is_dumb() else "\u2500" # ─
75
+ return Rule(title, characters=chars) if title else Rule(characters=chars)
76
+
77
+
78
+ # ── Standard message functions ────────────────────────────────────────────────
79
+
80
+ def print_info(message: str) -> None:
81
+ """Informational message — cyan bordered panel."""
82
+ console.print(_panel("Info", escape(message), style="cyan"))
83
+ console.print()
84
+
85
+
86
+ def print_success(message: str) -> None:
87
+ """Success message — green panel with check mark."""
88
+ console.print(
89
+ _panel("Success", f"[bold green]{_ok()}[/bold green] {escape(message)}", style="green")
90
+ )
91
+ console.print()
92
+
93
+
94
+ def print_warning(message: str) -> None:
95
+ """Warning message — yellow panel."""
96
+ console.print(
97
+ _panel("Warning", f"[bold yellow]{_warn()}[/bold yellow] {escape(message)}", style="yellow")
98
+ )
99
+ console.print()
100
+
101
+
102
+ def print_error(message: str) -> None:
103
+ """Error message — red panel."""
104
+ console.print(
105
+ _panel("Error", f"[bold red]{_err()}[/bold red] {escape(message)}", style="red")
106
+ )
107
+ console.print()
108
+
109
+
110
+ def print_question(message: str) -> None:
111
+ """User question — magenta panel."""
112
+ console.print(
113
+ _panel("Question", f"[bold magenta]?[/bold magenta] {escape(message)}", style="magenta")
114
+ )
115
+ console.print()
116
+
117
+
118
+ def print_answer(message: str) -> None:
119
+ """Agent answer — bright_cyan panel."""
120
+ console.print(_panel("Answer", escape(message), style="bright_cyan"))
121
+ console.print()
122
+
123
+
124
+ def print_summary(message: str) -> None:
125
+ """Summary panel — dim."""
126
+ console.print(_panel("Summary", escape(message), style="dim"))
127
+ console.print()
128
+
129
+
130
+ def print_verification(message: str) -> None:
131
+ """Proposed-edit verification — blue panel."""
132
+ console.print(_panel("Verification", escape(message), style="blue"))
133
+ console.print()
134
+
135
+
136
+ def print_prompt(message: str) -> None:
137
+ """Neutral prompt panel."""
138
+ console.print(_panel(None, escape(message)))
139
+ console.print()
140
+
141
+
142
+ def print_cli_output(content: Any, title: str | None = None, style: str = "") -> None:
143
+ """Generic CLI output. Accepts plain strings and Rich renderables (Tables, etc.)."""
144
+ if isinstance(content, str):
145
+ content = escape(content)
146
+ console.print(_panel(title, content, style=style))
147
+ console.print()
148
+
149
+
150
+ # ── Banner ────────────────────────────────────────────────────────────────────
151
+
152
+ _LOGO_RICH = (
153
+ "[bold cyan]██████╗ ██╗ ██╗██╗ ███████╗███████╗[/bold cyan]\n"
154
+ "[bold cyan]██╔══██╗██║ ██║██║ ██╔════╝██╔════╝[/bold cyan]\n"
155
+ "[bold cyan]██████╔╝██║ ██║██║ ███████╗█████╗ [/bold cyan]\n"
156
+ "[bold cyan]██╔═══╝ ██║ ██║██║ ╚════██║██╔══╝ [/bold cyan]\n"
157
+ "[bold cyan]██║ ╚██████╔╝███████╗███████║███████╗[/bold cyan]\n"
158
+ "[bold cyan]╚═╝ ╚═════╝ ╚══════╝╚══════╝╚══════╝[/bold cyan]\n\n"
159
+ " [bold white]Pulse AI[/bold white]\n"
160
+ f" [dim]Autonomous Project Assistant v{_VERSION}[/dim]"
161
+ )
162
+
163
+ _LOGO_ASCII = (
164
+ " ____ _ _ _ ____ _____\n"
165
+ "| _ \\| | | | | / ___|| ___|\n"
166
+ "| |_) | | | | | \\___ \\| |_\n"
167
+ "| __/| |_| | |___ ___) | _|\n"
168
+ "|_| \\___/|_____|____/|_|\n\n"
169
+ " Pulse AI\n"
170
+ f" Autonomous Project Assistant v{_VERSION}"
171
+ )
172
+
173
+
174
+ def print_banner(config: Any = None, runtime: Any = None) -> None:
175
+ """Full-width startup banner with optional status grid."""
176
+ logo = _LOGO_ASCII if _is_dumb() else _LOGO_RICH
177
+ console.print()
178
+ console.print(Align.center(logo))
179
+ console.print()
180
+
181
+ if config is not None:
182
+ _print_status_grid(config, runtime)
183
+
184
+ console.print(_rule())
185
+ console.print()
186
+
187
+
188
+ def _print_status_grid(config: Any, runtime: Any = None) -> None:
189
+ """Two-column status grid shown below the banner."""
190
+ auth = getattr(runtime, "auth", None)
191
+ auth_text = "Not signed in"
192
+ auth_style = "yellow"
193
+
194
+ if auth is not None and auth.is_authenticated():
195
+ info = auth.get_current_user_info()
196
+ if info:
197
+ _uname, display_name, email = info
198
+ if display_name and email:
199
+ auth_text = f"{escape(display_name)} ({escape(email)})"
200
+ elif email:
201
+ auth_text = escape(email)
202
+ else:
203
+ auth_text = f"@{escape(str(auth.current_user()))}"
204
+ else:
205
+ auth_text = f"@{escape(str(auth.current_user()))}"
206
+ auth_style = "bold green"
207
+
208
+ project_path = escape(str(getattr(getattr(config, "sandbox", None), "workspace_root", ".")))
209
+ if len(project_path) > 40:
210
+ project_path = "..." + project_path[-37:]
211
+
212
+ grid = Table.grid(padding=(0, 3))
213
+ grid.add_column(style="dim", no_wrap=True)
214
+ grid.add_column(style="bold", no_wrap=True)
215
+ grid.add_column(style="dim", no_wrap=True, min_width=4)
216
+ grid.add_column(no_wrap=True)
217
+
218
+ grid.add_row("Provider", f"[cyan]{escape(str(config.model.provider))}[/cyan]",
219
+ "Auth", f"[{auth_style}]{auth_text}[/{auth_style}]")
220
+ grid.add_row("Model", f"[cyan]{escape(str(config.model.name))}[/cyan]",
221
+ "Mode", f"[cyan]{escape(str(config.mode))}[/cyan]")
222
+ grid.add_row("Project", f"[dim]{project_path}[/dim]",
223
+ "Version", f"[dim]v{_VERSION}[/dim]")
224
+
225
+ console.print(Align.center(grid))
226
+ console.print()
227
+
228
+
229
+ # ── Auth prompt ───────────────────────────────────────────────────────────────
230
+
231
+ def print_auth_prompt() -> str:
232
+ """Display a styled sign-in chooser."""
233
+ inner = Table.grid(padding=(0, 2))
234
+ inner.add_column()
235
+ inner.add_row("[bold]You are not signed in.[/bold]")
236
+ inner.add_row("")
237
+ inner.add_row("[bold cyan][1][/bold cyan] Continue with Google")
238
+ inner.add_row("[bold white][2][/bold white] Continue as Guest")
239
+ inner.add_row("[dim][3][/dim] Exit")
240
+
241
+ console.print()
242
+ console.print(
243
+ Panel(
244
+ inner,
245
+ title="[bold cyan]Pulse — Sign In[/bold cyan]",
246
+ box=_box_style(),
247
+ border_style="cyan",
248
+ padding=(1, 3),
249
+ )
250
+ )
251
+ console.print()
252
+ try:
253
+ choice = input(" Select an option [1/2/3]: ").strip()
254
+ except (EOFError, KeyboardInterrupt):
255
+ choice = "2"
256
+ if choice not in {"1", "2", "3"}:
257
+ choice = "2"
258
+ return choice
259
+
260
+
261
+ def print_signed_in(display_name: str | None, email: str | None) -> None:
262
+ """Show a success card immediately after Google sign-in."""
263
+ ok = _ok()
264
+ rows: list[str] = []
265
+ if display_name:
266
+ rows.append(f"[bold green]{ok} {escape(display_name)}[/bold green]")
267
+ if email:
268
+ rows.append(f"[dim] {escape(email)}[/dim]")
269
+ if not rows:
270
+ rows.append(f"[bold green]{ok} Signed in[/bold green]")
271
+
272
+ inner = Table.grid(padding=(0, 1))
273
+ inner.add_column()
274
+ for row in rows:
275
+ inner.add_row(row)
276
+
277
+ console.print(
278
+ Panel(
279
+ inner,
280
+ title="[bold green]Signed In[/bold green]",
281
+ box=_box_style(),
282
+ border_style="green",
283
+ padding=(0, 2),
284
+ )
285
+ )
286
+ console.print()
287
+
288
+
289
+ # ── Provider & Model Selection UI ───────────────────────────────────────────────
290
+
291
+ def print_provider_selection(
292
+ providers: list[dict[str, Any]], active_provider: str
293
+ ) -> None:
294
+ """Render interactive provider selection table highlighting active provider."""
295
+ table = Table(
296
+ box=_box_style(),
297
+ show_header=True,
298
+ header_style="bold cyan",
299
+ title="Available AI Providers (Single-Active-Model Agent)",
300
+ )
301
+ table.add_column("#", style="bold yellow", justify="right", width=4)
302
+ table.add_column("Provider", style="bold white", width=18)
303
+ table.add_column("API Key Env Var", style="cyan", width=22)
304
+ table.add_column("Key Status", style="bold", width=18)
305
+ table.add_column("Default Model", style="dim")
306
+
307
+ for idx, prov in enumerate(providers, 1):
308
+ key = prov["key"]
309
+ is_active = key.lower() == active_provider.lower()
310
+ active_mark = " [bold cyan](Active)[/bold cyan]" if is_active else ""
311
+ name_str = f"{prov['display_name']}{active_mark}"
312
+
313
+ status_str = (
314
+ f"[green]{_ok()} Configured[/green]"
315
+ if prov["configured"]
316
+ else f"[yellow]{_warn()} Missing API Key[/yellow]"
317
+ )
318
+
319
+ table.add_row(
320
+ str(idx),
321
+ name_str,
322
+ prov["env_var"],
323
+ status_str,
324
+ prov["default_model"],
325
+ )
326
+
327
+ console.print()
328
+ console.print(table)
329
+ console.print()
330
+
331
+
332
+ def print_model_selection(
333
+ provider_name: str,
334
+ models: list[Any],
335
+ default_model: str,
336
+ active_model: str,
337
+ ) -> None:
338
+ """Render categorized models table with Speed, Context, and Best For metadata."""
339
+ table = Table(
340
+ box=_box_style(),
341
+ show_header=True,
342
+ header_style="bold cyan",
343
+ title=f"Recommended AI Models for {provider_name}",
344
+ )
345
+ table.add_column("#", style="bold yellow", justify="right", width=4)
346
+ table.add_column("Model Name", style="bold white", width=32)
347
+ table.add_column("Speed", style="magenta", width=14)
348
+ table.add_column("Context", style="cyan", width=10)
349
+ table.add_column("Best For", style="white", width=34)
350
+ table.add_column("Status / Tag", style="bold")
351
+
352
+ for idx, m in enumerate(models, 1):
353
+ m_name = getattr(m, "name", str(m))
354
+ speed = getattr(m, "speed", "Balanced")
355
+ context = getattr(m, "context_length", "128k")
356
+ best_for = getattr(m, "best_for", "General")
357
+ is_active = m_name.lower() == active_model.lower()
358
+ is_default = m_name.lower() == default_model.lower()
359
+
360
+ tags = []
361
+ if is_active:
362
+ tags.append("[bold cyan]Active[/bold cyan]")
363
+ if is_default:
364
+ tags.append("[green]Default[/green]")
365
+ tag_str = ", ".join(tags) if tags else "[dim]Supported[/dim]"
366
+
367
+ row_style = "bold cyan" if is_active else ""
368
+ table.add_row(
369
+ str(idx),
370
+ f"*{m_name}" if is_active else m_name,
371
+ speed,
372
+ context,
373
+ best_for,
374
+ tag_str,
375
+ style=row_style,
376
+ )
377
+
378
+ console.print()
379
+ console.print(table)
380
+ console.print("[dim]Option [C]: Enter a custom model identifier[/dim]\n")
381
+
382
+
383
+ def print_provider_changed_card(
384
+ provider_display: str, model_name: str, env_var: str, is_configured: bool
385
+ ) -> None:
386
+ """Card confirming saved provider/model selection."""
387
+ status_str = (
388
+ f"[green]{_ok()} {env_var} Configured[/green]"
389
+ if is_configured
390
+ else f"[yellow]{_warn()} {env_var} Missing (Run pulse keys)[/yellow]"
391
+ )
392
+
393
+ inner = Table.grid(padding=(0, 1))
394
+ inner.add_column(style="bold white", no_wrap=True)
395
+ inner.add_column(style="cyan")
396
+ inner.add_row("Active Provider: ", provider_display)
397
+ inner.add_row("Active Model: ", model_name)
398
+ inner.add_row("Key Status: ", status_str)
399
+ inner.add_row("Saved To: ", ".agent/provider.json")
400
+
401
+ console.print(
402
+ Panel(
403
+ inner,
404
+ title="[bold green]AI Provider Configuration Updated[/bold green]",
405
+ box=_box_style(),
406
+ border_style="green",
407
+ padding=(1, 3),
408
+ )
409
+ )
410
+ console.print()
411
+
412
+
413
+ def print_current_model_card(
414
+ provider_display: str,
415
+ model_name: str,
416
+ meta: Any,
417
+ env_var: str,
418
+ is_configured: bool,
419
+ ) -> None:
420
+ """Render details card for `pulse model current`."""
421
+ status_str = (
422
+ f"[green]{_ok()} Configured ({env_var})[/green]"
423
+ if is_configured
424
+ else f"[red]{_err()} Missing ({env_var})[/red]"
425
+ )
426
+
427
+ speed = getattr(meta, "speed", "Balanced") if meta else "Balanced"
428
+ context = getattr(meta, "context_length", "128k") if meta else "128k"
429
+ best_for = getattr(meta, "best_for", "General Assistance") if meta else "General Assistance"
430
+ category = getattr(meta, "category", "Custom") if meta else "Custom"
431
+
432
+ inner = Table.grid(padding=(0, 1))
433
+ inner.add_column(style="bold white", no_wrap=True)
434
+ inner.add_column(style="cyan")
435
+
436
+ inner.add_row("Active Provider: ", f"[bold cyan]{provider_display}[/bold cyan]")
437
+ inner.add_row("Active Model: ", f"[bold white]{model_name}[/bold white]")
438
+ inner.add_row("Category: ", category)
439
+ inner.add_row("Performance/Speed: ", speed)
440
+ inner.add_row("Context Length: ", context)
441
+ inner.add_row("Recommended For: ", best_for)
442
+ inner.add_row("Key Status: ", status_str)
443
+ inner.add_row("Config File: ", ".agent/provider.json")
444
+
445
+ console.print()
446
+ console.print(
447
+ Panel(
448
+ inner,
449
+ title="[bold cyan]Pulse — Active AI Model Configuration[/bold cyan]",
450
+ box=_box_style(),
451
+ border_style="cyan",
452
+ padding=(1, 3),
453
+ )
454
+ )
455
+ console.print()
456
+
457
+
458
+ def print_all_models_list(
459
+ providers_info: list[dict[str, Any]], active_provider: str, active_model: str
460
+ ) -> None:
461
+ """Render complete catalog of supported providers and models for `pulse model list`."""
462
+ console.print()
463
+ console.print(_rule("Pulse AI Model Catalog"))
464
+ console.print()
465
+
466
+ for p in providers_info:
467
+ is_active_prov = p["key"].lower() == active_provider.lower()
468
+ prov_title = f"[bold cyan]{p['display_name']}[/bold cyan]"
469
+ if is_active_prov:
470
+ prov_title += " [bold green](Active Provider)[/bold green]"
471
+
472
+ status_str = (
473
+ f"[green]{_ok()} {p['env_var']} Configured[/green]"
474
+ if p["configured"]
475
+ else f"[yellow]{_warn()} {p['env_var']} Missing[/yellow]"
476
+ )
477
+
478
+ table = Table(
479
+ box=_box_style(),
480
+ show_header=True,
481
+ header_style="bold cyan",
482
+ title=f"{prov_title} — {status_str}",
483
+ )
484
+ table.add_column("Model Name", style="bold white", width=32)
485
+ table.add_column("Speed", style="magenta", width=14)
486
+ table.add_column("Context", style="cyan", width=10)
487
+ table.add_column("Best For", style="white", width=34)
488
+ table.add_column("Tag", style="bold")
489
+
490
+ for m in p["models"]:
491
+ m_name = getattr(m, "name", str(m))
492
+ speed = getattr(m, "speed", "Balanced")
493
+ context = getattr(m, "context_length", "128k")
494
+ best_for = getattr(m, "best_for", "General")
495
+ is_active = (is_active_prov and m_name.lower() == active_model.lower())
496
+ is_default = m_name.lower() == p["default_model"].lower()
497
+
498
+ tags = []
499
+ if is_active:
500
+ tags.append("[bold cyan]Active[/bold cyan]")
501
+ if is_default:
502
+ tags.append("[green]Default[/green]")
503
+ tag_str = ", ".join(tags) if tags else "[dim]Supported[/dim]"
504
+
505
+ row_style = "bold cyan" if is_active else ""
506
+ table.add_row(
507
+ f"*{m_name}" if is_active else m_name,
508
+ speed,
509
+ context,
510
+ best_for,
511
+ tag_str,
512
+ style=row_style,
513
+ )
514
+
515
+ console.print(table)
516
+ console.print()
517
+
518
+
519
+ # ── Conversation Management UI ─────────────────────────────────────────────────
520
+
521
+
522
+ def _short_id(conv_id: str, length: int = 8) -> str:
523
+ return conv_id[:length]
524
+
525
+
526
+ def _fmt_dt(iso_dt: str) -> str:
527
+ """Format an ISO timestamp to a human-readable short string."""
528
+ try:
529
+ from datetime import datetime
530
+ dt = datetime.fromisoformat(iso_dt)
531
+ # Make it local-aware by stripping tz info for display
532
+ return dt.strftime("%Y-%m-%d %H:%M")
533
+ except (ValueError, TypeError):
534
+ return iso_dt[:16]
535
+
536
+
537
+ def print_chat_list(conversations: list, active_id: str) -> None:
538
+ """Render a table of all conversations."""
539
+ if not conversations:
540
+ console.print()
541
+ console.print(
542
+ _panel(
543
+ "Conversations",
544
+ "No conversations yet.\nStart a new one with [bold cyan]pulse chat new[/bold cyan].",
545
+ style="dim",
546
+ )
547
+ )
548
+ console.print()
549
+ return
550
+
551
+ table = Table(
552
+ box=_box_style(),
553
+ show_header=True,
554
+ header_style="bold cyan",
555
+ title="Pulse Conversations",
556
+ )
557
+ table.add_column("#", style="bold yellow", justify="right", width=4)
558
+ table.add_column("ID", style="dim", width=10)
559
+ table.add_column("Title", style="bold white", width=36)
560
+ table.add_column("Turns", style="cyan", justify="right", width=7)
561
+ table.add_column("Last Active", style="dim", width=18)
562
+ table.add_column("Status", style="bold", width=12)
563
+
564
+ for idx, conv in enumerate(conversations, 1):
565
+ is_active = conv.id == active_id
566
+ status_str = "[bold cyan]● Active[/bold cyan]" if is_active else "[dim]○ Idle[/dim]"
567
+ row_style = "bold cyan" if is_active else ""
568
+ table.add_row(
569
+ str(idx),
570
+ _short_id(conv.id),
571
+ conv.title,
572
+ str(conv.turn_count),
573
+ _fmt_dt(conv.updated_at),
574
+ status_str,
575
+ style=row_style,
576
+ )
577
+
578
+ console.print()
579
+ console.print(table)
580
+ console.print(
581
+ "[dim]Use [bold white]pulse chat switch <ID>[/bold white] to resume a conversation.[/dim]\n"
582
+ )
583
+
584
+
585
+ def print_chat_card(conv: object) -> None:
586
+ """Display a compact card showing the currently active conversation."""
587
+ conv_id = getattr(conv, "id", "")
588
+ title = getattr(conv, "title", "Conversation")
589
+ turn_count = getattr(conv, "turn_count", 0)
590
+ created_at = getattr(conv, "created_at", "")
591
+ updated_at = getattr(conv, "updated_at", "")
592
+
593
+ inner = Table.grid(padding=(0, 1))
594
+ inner.add_column(style="dim", no_wrap=True)
595
+ inner.add_column(style="cyan")
596
+ inner.add_row("Conversation:", f"[bold white]{title}[/bold white]")
597
+ inner.add_row("ID:", f"[dim]{_short_id(conv_id)}…[/dim]")
598
+ inner.add_row("Turns:", str(turn_count))
599
+ if created_at:
600
+ inner.add_row("Created:", _fmt_dt(created_at))
601
+ if updated_at and turn_count > 0:
602
+ inner.add_row("Last active:", _fmt_dt(updated_at))
603
+
604
+ console.print(
605
+ Panel(
606
+ inner,
607
+ title="[bold cyan]Active Conversation[/bold cyan]",
608
+ box=_box_style(),
609
+ border_style="cyan",
610
+ padding=(0, 2),
611
+ )
612
+ )
613
+ console.print()
614
+
615
+
616
+ def print_chat_created(conv: object) -> None:
617
+ """Success card for `pulse chat new`."""
618
+ conv_id = getattr(conv, "id", "")
619
+ title = getattr(conv, "title", "Conversation")
620
+
621
+ inner = Table.grid(padding=(0, 1))
622
+ inner.add_column(style="bold white", no_wrap=True)
623
+ inner.add_column(style="cyan")
624
+ inner.add_row("Title: ", title)
625
+ inner.add_row("ID: ", f"[dim]{conv_id}[/dim]")
626
+ inner.add_row("Status:", "[bold cyan]Active[/bold cyan]")
627
+ inner.add_row("Tip: ", "Use [bold]pulse[/bold] to start chatting in this conversation.")
628
+
629
+ console.print(
630
+ Panel(
631
+ inner,
632
+ title=f"[bold green]{_ok()} New Conversation Created[/bold green]",
633
+ box=_box_style(),
634
+ border_style="green",
635
+ padding=(1, 3),
636
+ )
637
+ )
638
+ console.print()
639
+
640
+
641
+ def print_chat_switched(conv: object) -> None:
642
+ """Confirmation card for `pulse chat switch`."""
643
+ conv_id = getattr(conv, "id", "")
644
+ title = getattr(conv, "title", "Conversation")
645
+ turn_count = getattr(conv, "turn_count", 0)
646
+ updated_at = getattr(conv, "updated_at", "")
647
+
648
+ inner = Table.grid(padding=(0, 1))
649
+ inner.add_column(style="bold white", no_wrap=True)
650
+ inner.add_column(style="cyan")
651
+ inner.add_row("Title: ", f"[bold white]{title}[/bold white]")
652
+ inner.add_row("ID: ", f"[dim]{_short_id(conv_id)}…[/dim]")
653
+ inner.add_row("Turns: ", str(turn_count))
654
+ if updated_at:
655
+ inner.add_row("Last active: ", _fmt_dt(updated_at))
656
+
657
+ console.print(
658
+ Panel(
659
+ inner,
660
+ title=f"[bold cyan]{_ok()} Switched Conversation[/bold cyan]",
661
+ box=_box_style(),
662
+ border_style="cyan",
663
+ padding=(1, 3),
664
+ )
665
+ )
666
+ console.print()
667
+
668
+
669
+ def print_chat_exported(path: object) -> None:
670
+ """Info card shown after a successful export."""
671
+ console.print(
672
+ Panel(
673
+ f"[green]{_ok()}[/green] Conversation exported to:\n[bold white]{path}[/bold white]",
674
+ title="[bold green]Export Complete[/bold green]",
675
+ box=_box_style(),
676
+ border_style="green",
677
+ padding=(1, 3),
678
+ )
679
+ )
680
+ console.print()
681
+
682
+
683
+ def print_chat_search_results(results: list, query: str, active_id: str = "") -> None:
684
+ """Render search results table."""
685
+ if not results:
686
+ console.print(
687
+ _panel("Search", f"No conversations found for query: [bold]{query}[/bold]", style="dim")
688
+ )
689
+ console.print()
690
+ return
691
+
692
+ table = Table(
693
+ box=_box_style(),
694
+ show_header=True,
695
+ header_style="bold cyan",
696
+ title=f'Search Results — "{query}"',
697
+ )
698
+ table.add_column("#", style="bold yellow", justify="right", width=4)
699
+ table.add_column("ID", style="dim", width=10)
700
+ table.add_column("Title", style="bold white", width=36)
701
+ table.add_column("Turns", style="cyan", justify="right", width=7)
702
+ table.add_column("Last Active", style="dim", width=18)
703
+ table.add_column("Status", style="bold", width=12)
704
+
705
+ for idx, conv in enumerate(results, 1):
706
+ is_active = getattr(conv, "id", "") == active_id
707
+ status_str = "[bold cyan]● Active[/bold cyan]" if is_active else "[dim]○ Idle[/dim]"
708
+ row_style = "bold cyan" if is_active else ""
709
+ table.add_row(
710
+ str(idx),
711
+ _short_id(getattr(conv, "id", "")),
712
+ getattr(conv, "title", ""),
713
+ str(getattr(conv, "turn_count", 0)),
714
+ _fmt_dt(getattr(conv, "updated_at", "")),
715
+ status_str,
716
+ style=row_style,
717
+ )
718
+
719
+ console.print()
720
+ console.print(table)
721
+ console.print()
722
+
723
+
724
+ # ── Help screen ───────────────────────────────────────────────────────────────
725
+
726
+ _HELP_GROUPS: list[tuple[str, list[tuple[str, str]]]] = [
727
+ ("Interactive shell", [
728
+ ("/COMMAND [ARGS]", "Run any Pulse command without leaving chat"),
729
+ ("/help", "Show this command reference"),
730
+ ("/clear", "Clear the terminal"),
731
+ ("/exit", "End the interactive session"),
732
+ ]),
733
+ ("Chat", [
734
+ ("pulse", "Start interactive chat (restores last conversation)"),
735
+ ('pulse ask "..."', "Single-shot question"),
736
+ ]),
737
+ ("Conversations", [
738
+ ("pulse chat new [--title T]", "Start a new conversation"),
739
+ ("pulse chat list", "List all conversations"),
740
+ ("pulse chat switch ID", "Resume a previous conversation by ID"),
741
+ ("pulse chat delete ID", "Permanently delete a conversation"),
742
+ ("pulse chat rename ID TITLE", "Rename a conversation"),
743
+ ("pulse chat export ID", "Export conversation to Markdown (default) or JSON"),
744
+ ("pulse chat search QUERY", "Full-text search across all conversations"),
745
+ ]),
746
+ ("Repository", [
747
+ ("pulse index", "Build / refresh repository index"),
748
+ ("pulse search QUERY", "Lexical-semantic file search"),
749
+ ("pulse symbols FILE", "List imports, classes, and functions"),
750
+ ]),
751
+ ("Git", [
752
+ ("pulse git", "Branch status, diff, commit suggestion"),
753
+ ("pulse mutations [--last]", "Show tracked file mutations"),
754
+ ("pulse rollback", "Restore latest approved edit"),
755
+ ("pulse edit FILE CONTENT", "Propose and approve a file change"),
756
+ ("pulse patch FILE TARGET OP", "Patch a function or class"),
757
+ ]),
758
+ ("Memory", [
759
+ ("pulse memory [--query Q]", "Inspect or set long-term memory"),
760
+ ]),
761
+ ("Authentication", [
762
+ ("pulse login", "Sign in with Google OAuth"),
763
+ ("pulse logout", "Sign out and clear tokens"),
764
+ ("pulse whoami", "Show signed-in user"),
765
+ ("pulse auth-status", "Check authentication state"),
766
+ ]),
767
+ ("Verification", [
768
+ ("pulse verify", "Run the project test suite"),
769
+ ("pulse doctor", "Check env, config, and provider readiness"),
770
+ ("pulse ci --pr NUMBER", "Run CI for a pull request"),
771
+ ]),
772
+ ("Configuration", [
773
+ ("pulse version", "Show the installed Pulse version"),
774
+ ("pulse keys", "Open secure provider-key manager"),
775
+ ("pulse keys list", "Show provider key status without values"),
776
+ ("pulse keys set PROVIDER", "Securely store a provider key"),
777
+ ("pulse keys rotate PROVIDER", "Securely replace a provider key"),
778
+ ("pulse keys remove PROVIDER", "Remove a workspace provider key"),
779
+ ("pulse model", "Interactive AI provider & model manager"),
780
+ ("pulse model current", "Display active AI provider & model details"),
781
+ ("pulse model list", "List all supported providers & models"),
782
+ ("pulse model PROVIDER [MODEL]", "Directly switch AI provider & model"),
783
+ ("pulse status", "Show agent configuration"),
784
+ ("pulse serve", "Start JSON-RPC WebSocket server"),
785
+ ("pulse tasks [--status S]", "List workspace tasks"),
786
+ ("pulse task ID", "Show task details"),
787
+ ("pulse resume ID", "Resume a paused or failed task"),
788
+ ("pulse cancel ID", "Cancel a task"),
789
+ ("pulse sessions", "List all sessions"),
790
+ ("pulse session ID", "Show session details"),
791
+ ("pulse resume-session ID", "Resume an inactive session"),
792
+ ]),
793
+ ]
794
+
795
+
796
+ def print_help_screen(*, interactive: bool = False) -> None:
797
+ """Render the full help table for terminal or interactive-shell syntax."""
798
+ outer = Table.grid(padding=(0, 0))
799
+ outer.add_column()
800
+
801
+ for group_name, commands in _HELP_GROUPS:
802
+ t = Table(
803
+ box=box.SIMPLE,
804
+ show_header=False,
805
+ padding=(0, 2),
806
+ title=f"[bold cyan]{group_name}[/bold cyan]",
807
+ title_style="bold cyan",
808
+ title_justify="left",
809
+ )
810
+ t.add_column("Command", style="bold white", no_wrap=True)
811
+ t.add_column("Description", style="dim")
812
+ for cmd, desc in commands:
813
+ display_command = cmd
814
+ if interactive:
815
+ if cmd == "pulse":
816
+ display_command = "<message>"
817
+ elif cmd.startswith("pulse "):
818
+ display_command = f"/{cmd.removeprefix('pulse ')}"
819
+ t.add_row(display_command, desc)
820
+ outer.add_row(t)
821
+ outer.add_row("")
822
+
823
+ console.print(
824
+ Panel(
825
+ outer,
826
+ title="[bold cyan]Pulse — Help[/bold cyan]",
827
+ box=_box_style(),
828
+ border_style="cyan",
829
+ padding=(1, 2),
830
+ )
831
+ )
832
+ console.print()
833
+
834
+
835
+ # ── Thinking spinner ──────────────────────────────────────────────────────────
836
+
837
+ _THINKING_PHASES = [
838
+ "Understanding request...",
839
+ "Searching repository...",
840
+ "Planning...",
841
+ "Generating response...",
842
+ ]
843
+
844
+
845
+ @contextmanager
846
+ def thinking_spinner() -> Generator[None, None, None]:
847
+ """Multi-phase animated spinner while the agent is working."""
848
+ if _is_dumb():
849
+ console.print("Thinking...")
850
+ yield
851
+ return
852
+
853
+ stop_event = threading.Event()
854
+
855
+ with console.status(
856
+ f"[cyan]{_THINKING_PHASES[0]}[/cyan]", spinner="dots"
857
+ ) as status:
858
+
859
+ def _rotate() -> None:
860
+ for phase in itertools.cycle(_THINKING_PHASES):
861
+ if stop_event.wait(timeout=3.0):
862
+ return
863
+ status.update(f"[cyan]{phase}[/cyan]")
864
+
865
+ t = threading.Thread(target=_rotate, daemon=True)
866
+ t.start()
867
+ try:
868
+ yield
869
+ finally:
870
+ stop_event.set()
871
+ t.join(timeout=2)
872
+
873
+
874
+ # ── Session footer & status cards ──────────────────────────────────────────────
875
+
876
+ @contextmanager
877
+ def task_spinner(description: str) -> Generator[None, None, None]:
878
+ """Rich spinner for long-running operations (indexing, tests, git, search)."""
879
+ if _is_dumb():
880
+ console.print(f"{description}...")
881
+ yield
882
+ return
883
+
884
+ with console.status(f"[cyan]{description}...[/cyan]", spinner="dots"):
885
+ yield
886
+
887
+
888
+ def print_status_cards(config: Any, provider: Any, runtime: Any = None) -> None:
889
+ """Display rich status cards for Provider, Model, Authentication,
890
+ Repository Indexing, Memory, Sandbox, and Safety Mode.
891
+ """
892
+ auth_str = "Signed Out"
893
+ auth_style = "yellow"
894
+ if runtime and hasattr(runtime, "auth") and runtime.auth.is_authenticated():
895
+ info = runtime.auth.get_current_user_info()
896
+ if info and info[1]:
897
+ auth_str = f"Signed In ({info[1]})"
898
+ else:
899
+ auth_str = f"Signed In (@{runtime.auth.current_user()})"
900
+ auth_style = "green"
901
+
902
+ repo_str = "Ready" if (runtime and hasattr(runtime, "repository")) else "Unindexed"
903
+ mem_str = "Active (SQLite)" if (runtime and hasattr(runtime, "memory")) else "Inactive"
904
+ sandbox_str = f"Writes={config.sandbox.allow_writes}, ReadsPerm={config.sandbox.require_permission_for_reads}"
905
+ safety_str = f"Mode={config.mode}, ActionsPerm={config.sandbox.require_permission_for_project_actions}"
906
+
907
+ grid = Table(box=_box_style(), show_header=True, header_style="bold cyan", title="Pulse Status Overview")
908
+ grid.add_column("Component", style="bold white", no_wrap=True)
909
+ grid.add_column("Details", style="cyan")
910
+ grid.add_column("State", style="bold")
911
+
912
+ api_key_var = getattr(provider, "api_key_env_var", "API Key")
913
+ key_configured = getattr(provider, "is_configured", False)
914
+
915
+ grid.add_row(
916
+ "Active Provider",
917
+ escape(str(config.model.provider)),
918
+ f"[green]{_ok()} Configured ({api_key_var})[/green]" if key_configured else f"[red]{_err()} Missing {api_key_var}[/red]",
919
+ )
920
+ grid.add_row("Active Model", escape(str(config.model.name)), f"[dim]Max tokens: {config.model.max_tokens}[/dim]")
921
+ grid.add_row("Authentication", auth_str, f"[{auth_style}]{auth_str}[/{auth_style}]")
922
+ grid.add_row("Repository Indexed", "Code intelligence & semantic search", f"[green]{_ok()} {repo_str}[/green]")
923
+ grid.add_row("Memory Status", "Episodic & preference storage", f"[green]{_ok()} {mem_str}[/green]")
924
+ grid.add_row("Sandbox Status", sandbox_str, f"[cyan]{config.sandbox.workspace_root}[/cyan]")
925
+ grid.add_row("Safety Mode", safety_str, "[blue]Single Model[/blue]")
926
+
927
+ console.print()
928
+ console.print(grid)
929
+ console.print()
930
+
931
+
932
+ def _get_git_branch() -> str | None:
933
+ """Return the current Git branch name, or None if unavailable."""
934
+ try:
935
+ result = subprocess.run( # noqa: PLW1510
936
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
937
+ capture_output=True,
938
+ text=True,
939
+ timeout=2,
940
+ **isolated_process_kwargs(),
941
+ )
942
+ if result.returncode == 0:
943
+ branch = result.stdout.strip()
944
+ return branch if branch and branch != "HEAD" else None
945
+ except (OSError, subprocess.SubprocessError):
946
+ pass
947
+ return None
948
+
949
+
950
+ def print_session_footer(
951
+ provider: str,
952
+ model: str,
953
+ project: str | None = None,
954
+ branch: str | None = None,
955
+ conversation: str | None = None,
956
+ ) -> None:
957
+ """One-line footer bar shown at the start of interactive sessions."""
958
+ if branch is None:
959
+ branch = _get_git_branch()
960
+
961
+ sep = " [dim]·[/dim] "
962
+ parts: list[str] = [
963
+ f"[dim]Provider[/dim] [cyan]{provider}[/cyan]",
964
+ f"[dim]Model[/dim] [cyan]{model}[/cyan]",
965
+ ]
966
+ if conversation:
967
+ short_conv = conversation if len(conversation) <= 28 else conversation[:25] + "…"
968
+ parts.append(f"[dim]Chat[/dim] [magenta]{short_conv}[/magenta]")
969
+ if branch:
970
+ parts.append(f"[dim]Branch[/dim] [yellow]{branch}[/yellow]")
971
+ if project:
972
+ short = project if len(project) <= 30 else "..." + project[-27:]
973
+ parts.append(f"[dim]Project[/dim] [white]{short}[/white]")
974
+
975
+ console.print(_rule())
976
+ console.print(" " + sep.join(parts))
977
+ console.print()