pisces-cli 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.
pisces/tui.py ADDED
@@ -0,0 +1,539 @@
1
+ """All terminal UI primitives for pisces.
2
+ Rich is our only TUI dependency"""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+
9
+ from rich import box
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+ from rich.progress import (
13
+ BarColumn,
14
+ Progress,
15
+ SpinnerColumn,
16
+ TaskProgressColumn,
17
+ TextColumn,
18
+ TimeElapsedColumn,
19
+ )
20
+ from rich.rule import Rule
21
+ from rich.syntax import Syntax
22
+ from rich.table import Table
23
+ from rich.text import Text
24
+ from rich.theme import Theme
25
+
26
+ # ─────────────────────────────────────────────────────────────
27
+ # Global Rich console with custom theme
28
+ # ─────────────────────────────────────────────────────────────
29
+
30
+ PISCES_THEME = Theme(
31
+ {
32
+ "critical": "bold red",
33
+ "high": "red",
34
+ "medium": "yellow",
35
+ "low": "cyan",
36
+ "info": "blue",
37
+ "success": "bold green",
38
+ "warning": "bold yellow",
39
+ "muted": "dim white",
40
+ "brand": "bold magenta",
41
+ "brand2": "bold cyan",
42
+ "header": "bold white on dark_magenta",
43
+ "code": "green",
44
+ "diff.add": "bold green",
45
+ "diff.del": "bold red",
46
+ "diff.hunk": "bold cyan",
47
+ }
48
+ )
49
+ console = Console(theme=PISCES_THEME, highlight=True)
50
+
51
+
52
+ # ─────────────────────────────────────────────────────────────
53
+ # ASCII Banner
54
+ # ─────────────────────────────────────────────────────────────
55
+ _BANNER_LINES = [
56
+ (" ██████╗ ██╗ ███████╗ ██████╗ ███████╗ ███████╗", "bold magenta"),
57
+ (" ██╔══██╗ ██║ ██╔════╝ ██╔════╝ ██╔════╝ ██╔════╝", "bold magenta"),
58
+ (" ██████╔╝ ██║ ███████╗ ██║ █████╗ ███████╗ ", "bold cyan"),
59
+ (" ██╔═══╝ ██║ ╚════██║ ██║ ██╔══╝ ╚════██║ ", "bold cyan"),
60
+ (" ██║ ██║ ███████║ ╚██████╗ ███████╗ ███████║ ", "bold blue"),
61
+ (" ╚═╝ ╚═╝ ╚══════╝ ╚═════╝ ╚══════╝ ╚══════╝ ", "bold blue"),
62
+ ]
63
+ _TAGLINE = " AI-assisted Python Security · Detect · Patch · Validate · Self-Heal"
64
+ _VERSION_LINE = " v0.1.0 · MIT License · pip install pisces"
65
+
66
+
67
+ def print_banner() -> None:
68
+
69
+ console.print()
70
+ banner_text = Text()
71
+ for line, style in _BANNER_LINES:
72
+ banner_text.append(line + "\n", style=style)
73
+ console.print(banner_text)
74
+ console.print(f"[bold yellow]{_TAGLINE}[/]")
75
+ console.print(f"[dim]{_VERSION_LINE}[/]")
76
+ console.print()
77
+
78
+ ### Severity badge helperz:###
79
+
80
+
81
+ _SEVERITY_STYLES: dict[str, tuple[str, str]] = {
82
+ "CRITICAL": ("critical", "● CRITICAL"),
83
+ "HIGH": ("high", "▲ HIGH "),
84
+ "MEDIUM": ("medium", "◆ MEDIUM "),
85
+ "LOW": ("low", "▼ LOW "),
86
+ "INFO": ("info", "ℹ INFO "),
87
+ }
88
+
89
+
90
+ def severity_badge(severity: str) -> Text:
91
+ # Rich severity badge returns.
92
+ severity = severity.upper()
93
+ style, label = _SEVERITY_STYLES.get(severity, ("muted", f" {severity}"))
94
+ return Text(label, style=style)
95
+
96
+ ### Spinners: for actually indicatijg loading or progress
97
+
98
+
99
+ @contextmanager
100
+ def spinner(message: str, success_message: str = "") -> Generator[None, None, None]:
101
+ with console.status(
102
+ f"[brand] {message}[/]",
103
+ spinner="dots",
104
+ spinner_style="bold magenta",
105
+ ):
106
+ yield
107
+ if success_message:
108
+ console.print(f" [success]✓[/] {success_message}")
109
+
110
+
111
+ def make_scan_progress() -> Progress:
112
+ """Progress bar forscan steps"""
113
+ return Progress(
114
+ SpinnerColumn(spinner_name="dots", style="bold magenta"),
115
+ TextColumn("[brand]{task.description}[/]", justify="left"),
116
+ BarColumn(bar_width=30, style="magenta", complete_style="cyan"),
117
+ TaskProgressColumn(),
118
+ TimeElapsedColumn(),
119
+ console=console,
120
+ transient=False,
121
+ )
122
+
123
+ ### Finding table:###
124
+
125
+
126
+ def print_findings_table(findings: list[dict]) -> None:
127
+ """Print a colorful findings table.
128
+ Each finding dict needs: severity, file, line, rule, description."""
129
+ if not findings:
130
+ console.print()
131
+ console.print(
132
+ Panel(
133
+ "[success]✓ No security vulnerabilities found.[/]\n"
134
+ "[dim]Your project looks clean![/]",
135
+ title="[bold green]Scan Complete[/]",
136
+ border_style="green",
137
+ padding=(1, 4),
138
+ )
139
+ )
140
+ return
141
+
142
+ table = Table(
143
+ title=(
144
+ f"[bold red]⚠ {len(findings)} Security "
145
+ f"Finding{'s' if len(findings) > 1 else ''} Detected[/]"
146
+ ),
147
+ title_justify="left",
148
+ box=box.ROUNDED,
149
+ border_style="red",
150
+ header_style="bold white",
151
+ show_lines=True,
152
+ padding=(0, 1),
153
+ )
154
+
155
+ table.add_column("Severity", style="bold", width=12, justify="center")
156
+ table.add_column("Location", style="cyan", min_width=22)
157
+ table.add_column("Rule ID", style="yellow", min_width=20)
158
+ table.add_column("Description", min_width=40)
159
+
160
+ _order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
161
+ sorted_findings = sorted(findings, key=lambda f: _order.get(f["severity"].upper(), 5))
162
+
163
+ for f in sorted_findings:
164
+ table.add_row(
165
+ severity_badge(f["severity"]),
166
+ f"{f['file']}:{f['line']}",
167
+ f["rule"],
168
+ f["description"],
169
+ )
170
+ console.print()
171
+ console.print(table)
172
+ console.print()
173
+
174
+ ### Finding detail panel for Pisces Explainations###
175
+
176
+
177
+ def print_finding_detail(finding: dict, source_snippet: str = "") -> None:
178
+ """Print a detail panel for a single finding"""
179
+ info = Table.grid(padding=(0, 2))
180
+ info.add_column(style="dim", justify="right")
181
+ info.add_column(style="white")
182
+
183
+ info.add_row("Rule:", f"[yellow]{finding['rule']}[/]")
184
+ info.add_row("File:", f"[cyan]{finding['file']}:{finding['line']}[/]")
185
+
186
+ info.add_row("Severity:", severity_badge(finding["severity"]))
187
+ info.add_row("Description:", finding.get("description", "_"))
188
+ info.add_row("Impact:", finding.get("impact", "_"))
189
+ info.add_row("Remediation:", finding.get("remediation", "_"))
190
+
191
+ console.print()
192
+ console.print(
193
+ Panel(
194
+ info,
195
+ title=f"[bold]Finding Detail-[yellow]{finding['rule']}[/][/]",
196
+ border_style="yellow",
197
+ padding=(1, 2),
198
+ )
199
+ )
200
+
201
+ if source_snippet:
202
+ console.print(
203
+ Panel(
204
+ Syntax(
205
+ source_snippet,
206
+ "python",
207
+ theme="monokai",
208
+ line_numbers=True,
209
+ highlight_lines={finding["line"]},
210
+ ),
211
+ title="[bold]Vulnerable Code[/]",
212
+ border_style="red",
213
+ padding=(0, 1),
214
+ )
215
+ )
216
+ console.print("[dim italic]ℹ No source modifications have been made.[/]")
217
+ console.print()
218
+
219
+ ### Diff viewer: ###
220
+
221
+
222
+ def print_diff(diff_text: str, filename: str = "") -> None:
223
+ """Display a unified diff with syntax highlighting."""
224
+ console.print()
225
+ console.print(
226
+ Panel(
227
+ Syntax(diff_text, "diff", theme="monokai", line_numbers=False),
228
+ title=f"[bold]Proposed Patch{'-' + filename if filename else ''}[/]",
229
+ border_style="cyan",
230
+ padding=(0, 1),
231
+ )
232
+ )
233
+ console.print()
234
+
235
+
236
+ ### HITL approval panel ###
237
+ def print_hitl_panel(
238
+ filename: str,
239
+ rule: str,
240
+ severity: str,
241
+ files_modified: int,
242
+ judge_decision: str,
243
+ risk: str,
244
+ ) -> None:
245
+ ### Print the HITL proposal + approval options. ###
246
+ grid = Table.grid(padding=(0, 2))
247
+ grid.add_column(style="dim", justify="right", min_width=18)
248
+ grid.add_column(style="white")
249
+
250
+ judge_color = "green" if judge_decision.upper() == "APPROVED" else "red"
251
+ risk_color = {"LOW": "green", "MEDIUM": "yellow", "HIGH": "red"}.get(risk.upper(), "white")
252
+
253
+ grid.add_row("File:", f"[cyan]{filename}[/]")
254
+ grid.add_row("Issue:", f"[yellow]{rule}[/]")
255
+ grid.add_row("Severity:", severity_badge(severity))
256
+ grid.add_row("Files Modified:", str(files_modified))
257
+ grid.add_row("LLM Judge:", f"[{judge_color}]{judge_decision.upper()}[/]")
258
+ grid.add_row("Risk:", f"[{risk_color}]{risk.upper()}[/]")
259
+
260
+ console.print()
261
+ console.print(
262
+ Panel(
263
+ grid,
264
+ title="[bold white on dark_magenta] PROPOSED SECURITY PATCH [/]",
265
+ border_style="magenta",
266
+ padding=(1, 2),
267
+ )
268
+ )
269
+
270
+ options = Table.grid(padding=(0, 3))
271
+ options.add_column(justify="center")
272
+ options.add_column(justify="center")
273
+ options.add_column(justify="center")
274
+ options.add_row(
275
+ "[bold green on black] Y [/][green] Apply patch[/]",
276
+ "[bold red on black] N [/][red] Reject[/]",
277
+ "[bold cyan on black] D [/][cyan] Show detailed diff[/]",
278
+ )
279
+ console.print(options)
280
+ console.print()
281
+
282
+
283
+ def prompt_hitl() -> str:
284
+ """Prompt user for HITL decision. Returns 'y', 'n', or 'd'."""
285
+ while True:
286
+ console.print("[dim]Enter you choice:[/]", end="")
287
+ choice = input().strip().lower()
288
+ if choice in ("y", "n", "d"):
289
+ return choice
290
+ console.print("[warning]Please Y, N, or D[/]")
291
+
292
+ """Step level status lines"""
293
+
294
+
295
+ def print_step_ok(message: str) -> None:
296
+ console.print(f" [success]✓[/] [white]{message}[/]")
297
+
298
+
299
+ def print_step_running(message: str) -> None:
300
+ console.print(f" [brand]◌[/] [dim]{message}[/]")
301
+
302
+
303
+ def print_step_fail(message: str) -> None:
304
+ console.print(f" [high]✗[/] [high]{message}[/]")
305
+
306
+
307
+ def print_section(title: str) -> None:
308
+ console.print()
309
+ console.print(Rule(f"[bold magenta]{title}[/]", style="magenta"))
310
+ console.print()
311
+
312
+
313
+ def print_success(message: str) -> None:
314
+ console.print()
315
+ console.print(Panel(f"[bold green]{message}[/]", border_style="green", padding=(0, 2)))
316
+ console.print()
317
+
318
+
319
+ def print_error(message: str, hint: str = "") -> None:
320
+ body = f"[high]{message}[/]"
321
+ if hint:
322
+ body += f"\n\n[dim]{hint}[/]"
323
+ console.print()
324
+ console.print(Panel(body, title="[bold red]Error[/]", border_style="red", padding=(0, 2)))
325
+ console.print()
326
+
327
+
328
+ def print_warning(message: str) -> None:
329
+ console.print(f"[warning]⚠ {message}[/]")
330
+
331
+ """History Table"""
332
+
333
+
334
+ def print_history_table(records: list[dict]) -> None:
335
+ if not records:
336
+ console.print(
337
+ Panel(
338
+ "[dim]No repair history found.\nRun [cyan]pisces fix <finding>[/] to start.[/]",
339
+ title="[bold]Repair History[/]",
340
+ border_style="blue",
341
+ )
342
+ )
343
+ return
344
+ table = Table(
345
+ title="[bold]Repair History[/]",
346
+ box=box.SIMPLE_HEAD,
347
+ border_style="blue",
348
+ header_style="bold cyan",
349
+ show_lines=False,
350
+ )
351
+ table.add_column("ID", style="dim", width=10)
352
+ table.add_column("File", style="cyan")
353
+ table.add_column("Rule", style="yellow")
354
+ table.add_column("Status", justify="center")
355
+ table.add_column("Attempts", justify="center")
356
+ table.add_column("Date", style="dim")
357
+ _status = {
358
+ "SUCCEEDED": "[success]SUCCEEDED[/]",
359
+ "FAILED": "[high]FAILED[/]",
360
+ "ROLLED_BACK": "[warning]ROLLED BACK[/]",
361
+ "REJECTED": "[muted]REJECTED[/]",
362
+ }
363
+ for r in records:
364
+ table.add_row(
365
+ r.get("id", "—")[:8],
366
+ r.get("file", "—"),
367
+ r.get("rule", "—"),
368
+ _status.get(r.get("status", ""), r.get("status", "—")),
369
+ str(r.get("attempts", 1)),
370
+ r.get("date", "—"),
371
+ )
372
+ console.print()
373
+ console.print(table)
374
+ console.print()
375
+
376
+ """Judge Result panel"""
377
+
378
+
379
+ def print_judge_result(result: dict) -> None:
380
+ decision = result.get("decision", "UNKNOWN").upper()
381
+ color = "green" if decision == "APPROVED" else "red"
382
+ grid = Table.grid(padding=(0, 2))
383
+ grid.add_column(style="dim", justify="right", min_width=22)
384
+ grid.add_column()
385
+ _yn = lambda v: "[success]✓ PASS[/]" if v else "[high]✗ FAIL[/]"
386
+ grid.add_row("Decision:", f"[bold {color}]{decision}[/]")
387
+ grid.add_row("Security remediated:", _yn(result.get("security_ok")))
388
+ grid.add_row("Functionality preserved:", _yn(result.get("functionality_ok")))
389
+ grid.add_row("Scope correct:", _yn(result.get("scope_ok")))
390
+ grid.add_row("Regression risk:", result.get("regression_risk", "—"))
391
+ grid.add_row("Reasoning:", result.get("reasoning", "—"))
392
+ console.print()
393
+ console.print(
394
+ Panel(
395
+ grid,
396
+ title=f"[bold]LLM Judge Result — [{color}]{decision}[/][/]",
397
+ border_style=color,
398
+ padding=(1, 2),
399
+ )
400
+ )
401
+ console.print()
402
+
403
+ """Self-Healing messages"""
404
+
405
+
406
+ def print_self_healing_start(attempt: int, max_attempts: int) -> None:
407
+ console.print()
408
+ console.print(
409
+ Panel(
410
+ f"[warning]Validation failed. Starting self-healing attempt "
411
+ f"[bold]{attempt}/{max_attempts}[/]...[/]",
412
+ border_style="yellow",
413
+ padding=(0, 2),
414
+ )
415
+ )
416
+ console.print()
417
+
418
+
419
+ def print_self_healing_failed_all(max_attempts: int) -> None:
420
+ console.print()
421
+ console.print(
422
+ Panel(
423
+ f"[high]Self-healing failed after {max_attempts} attempt(s).\n\n"
424
+ "[bold]Your original code has been restored.[/]\n"
425
+ "[dim]No changes were left in the project.[/]",
426
+ title="[bold red]❌ Self-Healing Exhausted[/]",
427
+ border_style="red",
428
+ padding=(1, 3),
429
+ )
430
+ )
431
+ console.print()
432
+
433
+
434
+ def print_hitl_approval(
435
+ finding: dict,
436
+ patch_summary: str,
437
+ judge_result: dict,
438
+ ) -> bool:
439
+ """Display the judge's assessment and ask the user to approve the patch."""
440
+ from rich.panel import Panel
441
+ from rich.prompt import Confirm
442
+ from rich.table import Table
443
+
444
+ console.print()
445
+
446
+ # Display the finding.
447
+ details = Table.grid(padding=(0, 2))
448
+ details.add_column(style="dim", justify="right")
449
+ details.add_column()
450
+
451
+ details.add_row("Rule:", str(finding.get("rule", "Unknown")))
452
+ details.add_row("File:", str(finding.get("file", "Unknown")))
453
+ details.add_row("Line:", str(finding.get("line", "Unknown")))
454
+ details.add_row("Severity:", str(finding.get("severity", "Unknown")))
455
+ details.add_row("Issue:", str(finding.get("description", "Not available")))
456
+
457
+ console.print(
458
+ Panel(
459
+ details,
460
+ title="[bold yellow]Human Review Required[/]",
461
+ border_style="yellow",
462
+ padding=(1, 2),
463
+ )
464
+ )
465
+
466
+ # Display the judge's decision.
467
+ judge_table = Table.grid(padding=(0, 2))
468
+ judge_table.add_column(style="dim", justify="right")
469
+ judge_table.add_column()
470
+
471
+ judge_table.add_row(
472
+ "Decision:",
473
+ str(judge_result.get("decision", "Unknown")),
474
+ )
475
+ judge_table.add_row(
476
+ "Security OK:",
477
+ str(judge_result.get("security_ok", "Unknown")),
478
+ )
479
+ judge_table.add_row(
480
+ "Functionality OK:",
481
+ str(judge_result.get("functionality_ok", "Unknown")),
482
+ )
483
+ judge_table.add_row(
484
+ "Scope OK:",
485
+ str(judge_result.get("scope_ok", "Unknown")),
486
+ )
487
+ judge_table.add_row(
488
+ "Regression risk:",
489
+ str(judge_result.get("regression_risk", "Unknown")),
490
+ )
491
+ judge_table.add_row(
492
+ "Patch quality:",
493
+ str(judge_result.get("patch_quality", "Unknown")),
494
+ )
495
+ judge_table.add_row(
496
+ "Completeness:",
497
+ str(judge_result.get("completeness", "Unknown")),
498
+ )
499
+
500
+ console.print(
501
+ Panel(
502
+ judge_table,
503
+ title="[bold cyan]LLM Judge Assessment[/]",
504
+ border_style="cyan",
505
+ padding=(1, 2),
506
+ )
507
+ )
508
+
509
+ console.print(
510
+ Panel(
511
+ str(judge_result.get("reasoning", "No reasoning provided.")),
512
+ title="[bold]Judge Reasoning[/]",
513
+ border_style="dim",
514
+ padding=(1, 2),
515
+ )
516
+ )
517
+
518
+ console.print(f"\n [bold]Proposed patch:[/] {patch_summary}")
519
+
520
+ # Default to rejection if the user presses Enter.
521
+ return Confirm.ask(
522
+ "\n[bold yellow]Do you approve applying this patch?[/]",
523
+ default=False,
524
+ )
525
+
526
+
527
+ def _sev_badge(severity: str) -> str:
528
+ """Return a Rich-styled badge for a finding severity."""
529
+ colors = {
530
+ "CRITICAL": "bold white on red",
531
+ "HIGH": "bold red",
532
+ "MEDIUM": "bold yellow",
533
+ "LOW": "bold cyan",
534
+ "INFO": "dim",
535
+ }
536
+
537
+ severity = str(severity).upper()
538
+ color = colors.get(severity, "white")
539
+ return f"[{color}]{severity}[/]"