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.
@@ -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