pulse-ai-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.
- pulse_ai_cli-0.1.0.dist-info/METADATA +49 -0
- pulse_ai_cli-0.1.0.dist-info/RECORD +27 -0
- pulse_ai_cli-0.1.0.dist-info/WHEEL +4 -0
- pulse_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pulse_cli/__init__.py +3 -0
- pulse_cli/__main__.py +4 -0
- pulse_cli/app.py +103 -0
- pulse_cli/client.py +197 -0
- pulse_cli/commands/__init__.py +0 -0
- pulse_cli/commands/auth.py +114 -0
- pulse_cli/commands/extract.py +199 -0
- pulse_cli/commands/jobs.py +53 -0
- pulse_cli/commands/open_platform.py +25 -0
- pulse_cli/commands/schema.py +124 -0
- pulse_cli/commands/split.py +63 -0
- pulse_cli/commands/tables.py +52 -0
- pulse_cli/commands/usage.py +109 -0
- pulse_cli/config.py +141 -0
- pulse_cli/errors.py +85 -0
- pulse_cli/oauth.py +95 -0
- pulse_cli/output.py +87 -0
- pulse_cli/repl.py +149 -0
- pulse_cli/runtime.py +61 -0
- pulse_cli/status.py +10 -0
- pulse_cli/tui/__init__.py +0 -0
- pulse_cli/tui/dashboard.py +153 -0
- pulse_cli/tui/logo.py +76 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""`pulse` (bare) — the TUI dashboard: animated logo + account/usage panels.
|
|
2
|
+
|
|
3
|
+
Read-only: only GET /pages and GET /api/v1/billing/subscription are called.
|
|
4
|
+
Network work runs in thread workers so the animation never stutters.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from textual.app import App, ComposeResult
|
|
9
|
+
from textual.containers import Horizontal, Vertical, VerticalScroll
|
|
10
|
+
from textual.widgets import Footer, Static
|
|
11
|
+
|
|
12
|
+
from pulse_cli import __version__
|
|
13
|
+
from pulse_cli import config as cfg
|
|
14
|
+
from pulse_cli.client import PlatformClient, PulseClient
|
|
15
|
+
from pulse_cli.errors import PulseCliError
|
|
16
|
+
from pulse_cli.tui.logo import LOGO_COLS, LOGO_ROWS, render_logo
|
|
17
|
+
|
|
18
|
+
FRAME_SECONDS = 1 / 12 # smooth but cheap
|
|
19
|
+
|
|
20
|
+
_COMMANDS: list[tuple[str, str]] = [
|
|
21
|
+
("pulse extract report.pdf", "convert a document to markdown + json"),
|
|
22
|
+
("pulse extract ./invoices --no-wait", "submit a whole folder, fetch results later"),
|
|
23
|
+
('pulse schema generate --prompt "vendor, total, date"', "draft an extraction schema with AI"),
|
|
24
|
+
("pulse schema apply <extraction_id> --schema s.json", "pull structured fields from a document"),
|
|
25
|
+
('pulse split <extraction_id> -t "intro:opening section"', "split a document into sections by topic"),
|
|
26
|
+
("pulse tables <extraction_id>", "extract tables (html or json)"),
|
|
27
|
+
("pulse jobs get <job_id> --watch", "watch or resume an async job"),
|
|
28
|
+
]
|
|
29
|
+
_CMD_PAD = max(len(c) for c, _ in _COMMANDS) + 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_started_text() -> str:
|
|
33
|
+
lines = [f"[#E9F0F8]{c:<{_CMD_PAD}}[/][#85C4E4]{d}[/]" for c, d in _COMMANDS]
|
|
34
|
+
lines.append("")
|
|
35
|
+
lines.append(
|
|
36
|
+
"[#0E7BC9]tip[/] [#CCDEE4]add [#E9F0F8]--json[/] to any command for raw output, "
|
|
37
|
+
"[#E9F0F8]-o <file>[/] to save, [#E9F0F8]-h[/] for help[/]"
|
|
38
|
+
)
|
|
39
|
+
return "\n".join(lines)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Logo(Static):
|
|
43
|
+
def on_mount(self) -> None:
|
|
44
|
+
self._frame = 0
|
|
45
|
+
self.update(render_logo(0))
|
|
46
|
+
self.set_interval(FRAME_SECONDS, self._tick)
|
|
47
|
+
|
|
48
|
+
def _tick(self) -> None:
|
|
49
|
+
self._frame += 1
|
|
50
|
+
self.update(render_logo(self._frame))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Dashboard(App):
|
|
54
|
+
TITLE = "Pulse"
|
|
55
|
+
BINDINGS = [("q", "quit", "Quit"), ("r", "refresh", "Refresh")]
|
|
56
|
+
|
|
57
|
+
CSS = f"""
|
|
58
|
+
Screen {{ background: #02182E; }}
|
|
59
|
+
#body {{ padding: 1 3 0 3; scrollbar-size-vertical: 1; }}
|
|
60
|
+
#header {{ height: {LOGO_ROWS}; margin-bottom: 1; }}
|
|
61
|
+
#logo {{ width: {LOGO_COLS}; height: {LOGO_ROWS}; }}
|
|
62
|
+
#brand {{ width: 1fr; height: {LOGO_ROWS}; padding-left: 5; align-vertical: middle; }}
|
|
63
|
+
#wordmark {{ color: #E9F0F8; text-style: bold; height: auto; }}
|
|
64
|
+
#tagline {{ color: #85C4E4; height: auto; margin-top: 1; }}
|
|
65
|
+
#panels {{ height: auto; margin-bottom: 1; }}
|
|
66
|
+
.panel {{
|
|
67
|
+
height: auto;
|
|
68
|
+
border: round #0E7BC9 50%;
|
|
69
|
+
border-title-color: #98E2FD;
|
|
70
|
+
border-title-style: bold;
|
|
71
|
+
padding: 1 2;
|
|
72
|
+
background: #02182E;
|
|
73
|
+
}}
|
|
74
|
+
#account {{ width: 1fr; margin-right: 2; }}
|
|
75
|
+
#usage {{ width: 1fr; }}
|
|
76
|
+
#commands {{ width: 100%; margin-bottom: 1; }}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
super().__init__()
|
|
81
|
+
self.conf = cfg.load()
|
|
82
|
+
|
|
83
|
+
def compose(self) -> ComposeResult:
|
|
84
|
+
with VerticalScroll(id="body"):
|
|
85
|
+
with Horizontal(id="header"):
|
|
86
|
+
yield Logo(id="logo")
|
|
87
|
+
with Vertical(id="brand"):
|
|
88
|
+
yield Static("P U L S E", id="wordmark")
|
|
89
|
+
yield Static(f"document extraction · cli v{__version__}", id="tagline")
|
|
90
|
+
with Horizontal(id="panels"):
|
|
91
|
+
account = Static("loading…", id="account", classes="panel")
|
|
92
|
+
account.border_title = "account"
|
|
93
|
+
yield account
|
|
94
|
+
usage = Static("loading…", id="usage", classes="panel")
|
|
95
|
+
usage.border_title = "usage"
|
|
96
|
+
yield usage
|
|
97
|
+
commands = Static(_get_started_text(), id="commands", classes="panel")
|
|
98
|
+
commands.border_title = "get started"
|
|
99
|
+
yield commands
|
|
100
|
+
yield Footer()
|
|
101
|
+
|
|
102
|
+
def on_mount(self) -> None:
|
|
103
|
+
self.action_refresh()
|
|
104
|
+
|
|
105
|
+
def action_refresh(self) -> None:
|
|
106
|
+
self.run_worker(self._load_account, thread=True, exclusive=True, group="account")
|
|
107
|
+
self.run_worker(self._load_usage, thread=True, exclusive=True, group="usage")
|
|
108
|
+
|
|
109
|
+
# --- data workers (thread) -------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _load_account(self) -> None:
|
|
112
|
+
lines = [
|
|
113
|
+
f"[#85C4E4]key[/] [#E9F0F8]{cfg.redact(self.conf.api_key)}[/] [#85C4E4]({self.conf.key_source})[/]",
|
|
114
|
+
f"[#85C4E4]engine[/] [#E9F0F8]{self.conf.api_url}[/]",
|
|
115
|
+
]
|
|
116
|
+
try:
|
|
117
|
+
with PulseClient(self.conf.api_key, self.conf.api_url) as client:
|
|
118
|
+
client.pages() # cheap auth/connectivity probe
|
|
119
|
+
lines.append("[#85C4E4]status[/] [green]● connected[/]")
|
|
120
|
+
except PulseCliError as exc:
|
|
121
|
+
lines.append(f"[#85C4E4]status[/] [red]● {exc}[/]")
|
|
122
|
+
self.call_from_thread(self.query_one("#account", Static).update, "\n".join(lines))
|
|
123
|
+
|
|
124
|
+
def _load_usage(self) -> None:
|
|
125
|
+
lines: list[str] = []
|
|
126
|
+
try:
|
|
127
|
+
with PulseClient(self.conf.api_key, self.conf.api_url) as client:
|
|
128
|
+
pages = client.pages().get("total_pages_processed", 0)
|
|
129
|
+
lines.append(f"[#85C4E4]pages processed[/] [#E9F0F8 bold]{pages:,}[/]")
|
|
130
|
+
except PulseCliError as exc:
|
|
131
|
+
lines.append(f"[red]{exc}[/]")
|
|
132
|
+
|
|
133
|
+
if self.conf.platform_url:
|
|
134
|
+
with PlatformClient(self.conf.api_key, self.conf.platform_url) as pc:
|
|
135
|
+
sub = pc.subscription()
|
|
136
|
+
if sub:
|
|
137
|
+
plan = sub.get("plan") or sub.get("plan_name") or "—"
|
|
138
|
+
status = sub.get("status") or "—"
|
|
139
|
+
lines.append(f"[#85C4E4]plan[/] [#E9F0F8]{plan}[/]")
|
|
140
|
+
lines.append(f"[#85C4E4]subscription[/] [#E9F0F8]{status}[/]")
|
|
141
|
+
else:
|
|
142
|
+
lines.append("[#85C4E4]plan[/] [#CCDEE4]—[/]")
|
|
143
|
+
self.call_from_thread(self.query_one("#usage", Static).update, "\n".join(lines))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def run_dashboard() -> None:
|
|
147
|
+
conf = cfg.load()
|
|
148
|
+
if not conf.api_key:
|
|
149
|
+
from pulse_cli.output import console
|
|
150
|
+
|
|
151
|
+
console.print("[muted]Not logged in. Run[/muted] [brand]pulse login[/brand] [muted]to get started.[/muted]")
|
|
152
|
+
raise SystemExit(1)
|
|
153
|
+
Dashboard().run()
|
pulse_cli/tui/logo.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Pulse logo as terminal art: the 8×8 rounded-square grid from logo-icon.svg.
|
|
2
|
+
|
|
3
|
+
The brand mark is an 8×8 grid of squares at 10% opacity with eight full-opacity
|
|
4
|
+
cells tracing a heartbeat waveform. We render each cell as a 2-char block and
|
|
5
|
+
animate a glow that travels along the waveform left-to-right, easing through the
|
|
6
|
+
brand palette (navy -> pulse blue -> sky).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
GRID = 8
|
|
13
|
+
|
|
14
|
+
# Waveform cells in (row, col), ordered left-to-right (decoded from logo-icon.svg).
|
|
15
|
+
HEARTBEAT: list[tuple[int, int]] = [
|
|
16
|
+
(4, 0), (4, 1), (3, 2), (2, 3), (3, 4), (4, 5), (5, 6), (4, 7),
|
|
17
|
+
]
|
|
18
|
+
_WAVE_INDEX = {cell: i for i, cell in enumerate(HEARTBEAT)}
|
|
19
|
+
|
|
20
|
+
# Palette stops for the traveling glow (dim -> peak).
|
|
21
|
+
_DIM_BG = "#0A2238" # 10%-opacity squares (navy-tinted)
|
|
22
|
+
_WAVE_BASE = "#0E7BC9" # brand pulse blue (resting waveform)
|
|
23
|
+
_GLOW = ["#0E7BC9", "#4A9ED6", "#85C4E4", "#98E2FD"] # base -> peak
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _lerp(a: str, b: str, t: float) -> str:
|
|
27
|
+
"""Linear hex-color interpolation, t in [0,1]."""
|
|
28
|
+
av = [int(a[i : i + 2], 16) for i in (1, 3, 5)]
|
|
29
|
+
bv = [int(b[i : i + 2], 16) for i in (1, 3, 5)]
|
|
30
|
+
return "#{:02x}{:02x}{:02x}".format(*(round(x + (y - x) * t) for x, y in zip(av, bv)))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def wave_color(index: int, frame: int, period: int = 24) -> str:
|
|
34
|
+
"""Color of waveform cell ``index`` at animation ``frame``.
|
|
35
|
+
|
|
36
|
+
A glow peak sweeps the 8 waveform cells once per ``period`` frames, with a
|
|
37
|
+
short trailing falloff behind it.
|
|
38
|
+
"""
|
|
39
|
+
pos = (frame % period) / period * (len(HEARTBEAT) + 4) - 2 # overshoot so it fully exits
|
|
40
|
+
dist = pos - index
|
|
41
|
+
if 0 <= dist < 3: # trailing glow
|
|
42
|
+
t = 1.0 - dist / 3
|
|
43
|
+
elif -1 < dist < 0: # leading edge
|
|
44
|
+
t = 1.0 + dist
|
|
45
|
+
else:
|
|
46
|
+
t = 0.0
|
|
47
|
+
if t <= 0:
|
|
48
|
+
return _WAVE_BASE
|
|
49
|
+
# Ease through the glow ramp to the peak color.
|
|
50
|
+
return _lerp(_WAVE_BASE, _GLOW[-1], t)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Rendered size: cells are "██" (2 cols x 1 row ≈ square in a terminal);
|
|
54
|
+
# gaps are 2 cols / 1 row, so cell and gap strides are square on both axes.
|
|
55
|
+
LOGO_COLS = GRID * 4 - 2 # 30
|
|
56
|
+
LOGO_ROWS = GRID * 2 - 1 # 15
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_logo(frame: int = 0) -> Text:
|
|
60
|
+
"""Render one animation frame of the logo grid as a rich Text.
|
|
61
|
+
|
|
62
|
+
Pure full-block glyphs (no half-blocks), so it renders identically in any
|
|
63
|
+
terminal/font. A 2-space gap between columns and a blank line between
|
|
64
|
+
rows keep the mark square overall (terminal cells are ~1:2 wide:tall).
|
|
65
|
+
"""
|
|
66
|
+
out = Text()
|
|
67
|
+
for r in range(GRID):
|
|
68
|
+
for c in range(GRID):
|
|
69
|
+
idx = _WAVE_INDEX.get((r, c))
|
|
70
|
+
color = _DIM_BG if idx is None else wave_color(idx, frame)
|
|
71
|
+
out.append("██", style=color)
|
|
72
|
+
if c < GRID - 1:
|
|
73
|
+
out.append(" ")
|
|
74
|
+
if r < GRID - 1:
|
|
75
|
+
out.append("\n\n")
|
|
76
|
+
return out
|