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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- 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()
|