cinna-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.
cinna/sync_tui.py ADDED
@@ -0,0 +1,352 @@
1
+ """Textual TUI for `cinna sync start`.
2
+
3
+ Two tabs:
4
+ * **Sync** — friendly status block with a live activity log derived from
5
+ polling ``mutagen sync list``. This is the default view.
6
+ * **Details** — raw ``mutagen sync list --long <name>`` output, what
7
+ Mutagen itself shows a power user.
8
+
9
+ The TUI polls the Mutagen daemon once per second. Ctrl-C / ``q`` quits; the
10
+ caller (``sync_session.run_foreground``) is responsible for terminating the
11
+ Mutagen session once the TUI exits — sync does not outlive the terminal.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import os
19
+ import subprocess
20
+ from collections import deque
21
+ from datetime import datetime
22
+ from typing import Any
23
+
24
+ from textual.app import App, ComposeResult
25
+ from textual.binding import Binding
26
+ from textual.containers import VerticalScroll
27
+ from textual.widgets import Footer, Header, Log, Static, TabbedContent, TabPane
28
+
29
+ from .config import CinnaConfig
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ def _fmt_size(n: int) -> str:
35
+ if n < 1024:
36
+ return f"{n} B"
37
+ for unit in ("KB", "MB", "GB", "TB"):
38
+ n /= 1024.0
39
+ if n < 1024:
40
+ return f"{n:.1f} {unit}"
41
+ return f"{n:.1f} PB"
42
+
43
+
44
+ def _state_pill(session: dict | None) -> str:
45
+ if session is None:
46
+ return "[red]⬤ Session not found[/red]"
47
+ if session.get("paused"):
48
+ return "[yellow]⬤ Paused[/yellow]"
49
+ alpha_conn = bool((session.get("alpha") or {}).get("connected"))
50
+ beta_conn = bool((session.get("beta") or {}).get("connected"))
51
+ last_error = session.get("lastError")
52
+ status = (session.get("status") or "").lower()
53
+ if last_error:
54
+ return f"[red]⬤ Error[/red]"
55
+ if not (alpha_conn and beta_conn):
56
+ return "[red]⬤ Disconnected[/red]"
57
+ if status in {"watching", "watching-changes", "ready"}:
58
+ return "[green]⬤ Watching for changes[/green]"
59
+ if status in {"scanning", "staging", "transitioning", "saving", "reconciling", "transferring"}:
60
+ return f"[cyan]⬤ {status.title()}[/cyan]"
61
+ return f"[cyan]⬤ {status.title() or 'Connected'}[/cyan]"
62
+
63
+
64
+ class SyncApp(App):
65
+ """Live status TUI for a single Mutagen sync session."""
66
+
67
+ CSS = """
68
+ Screen { background: $surface; }
69
+ #status { height: 7; padding: 1 2; border: round $primary; margin: 1 1 0 1; }
70
+ #stats { height: 3; padding: 0 2; margin: 0 1; color: $text; }
71
+ #activity { border: round $primary; margin: 0 1 1 1; }
72
+ #details-scroll { padding: 1 2; margin: 1; }
73
+ #details-text { color: $text; }
74
+ """
75
+
76
+ # Tab order matters for left/right cycling.
77
+ TAB_IDS: tuple[str, ...] = ("sync-tab", "details-tab")
78
+
79
+ BINDINGS = [
80
+ Binding("q", "quit", "Quit", show=True, priority=True),
81
+ Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
82
+ Binding("left", "cycle_tab(-1)", "◀ Tab", show=True, priority=True),
83
+ Binding("right", "cycle_tab(1)", "Tab ▶", show=True, priority=True),
84
+ Binding("1", "show_tab('sync-tab')", "Sync", show=False, priority=True),
85
+ Binding("2", "show_tab('details-tab')", "Details", show=False, priority=True),
86
+ ]
87
+
88
+ POLL_INTERVAL = 1.0
89
+ MAX_LOG_LINES = 500
90
+
91
+ def __init__(
92
+ self,
93
+ config: CinnaConfig,
94
+ session_name: str,
95
+ mutagen_env: dict[str, str],
96
+ ) -> None:
97
+ super().__init__()
98
+ self.config = config
99
+ self.session_name = session_name
100
+ self._env = mutagen_env
101
+ self._prev: dict | None = None
102
+ self._poll_task: asyncio.Task | None = None
103
+
104
+ # ── Layout ────────────────────────────────────────────────────────────
105
+
106
+ def compose(self) -> ComposeResult:
107
+ yield Header(show_clock=True)
108
+ with TabbedContent(initial="sync-tab"):
109
+ with TabPane("Sync", id="sync-tab"):
110
+ yield Static("", id="status")
111
+ yield Static("", id="stats")
112
+ yield Log(
113
+ id="activity",
114
+ highlight=False,
115
+ auto_scroll=True,
116
+ max_lines=self.MAX_LOG_LINES,
117
+ )
118
+ with TabPane("Details", id="details-tab"):
119
+ with VerticalScroll(id="details-scroll"):
120
+ yield Static("Loading…", id="details-text")
121
+ yield Footer()
122
+
123
+ # ── Lifecycle ─────────────────────────────────────────────────────────
124
+
125
+ async def on_mount(self) -> None:
126
+ self.title = f"cinna sync — {self.config.agent_name}"
127
+ self.sub_title = self.session_name
128
+ self._disable_mouse_tracking()
129
+ self._poll_task = asyncio.create_task(self._poll_loop())
130
+ await self._refresh_once()
131
+
132
+ async def on_unmount(self) -> None:
133
+ if self._poll_task is not None:
134
+ self._poll_task.cancel()
135
+
136
+ def _disable_mouse_tracking(self) -> None:
137
+ """Turn off the mouse-tracking modes textual enabled on startup.
138
+
139
+ Some terminal/shell combinations echo SGR mouse sequences as literal
140
+ text in the scrollback instead of consuming them as input events. We
141
+ don't use the mouse in this app (keyboard-only), so disabling tracking
142
+ makes the issue impossible.
143
+
144
+ Sequences mirror the ones textual emits to enable tracking — disable
145
+ variants use ``l`` instead of ``h``.
146
+ """
147
+ import sys as _sys
148
+ try:
149
+ _sys.__stdout__.write(
150
+ "\033[?1000l" # X10 mouse off
151
+ "\033[?1002l" # button-event tracking off
152
+ "\033[?1003l" # any-event tracking off
153
+ "\033[?1006l" # SGR extended mode off
154
+ "\033[?1015l" # URxvt extended mode off
155
+ )
156
+ _sys.__stdout__.flush()
157
+ except Exception:
158
+ pass
159
+
160
+ def action_show_tab(self, tab_id: str) -> None:
161
+ self.query_one(TabbedContent).active = tab_id
162
+
163
+ def action_cycle_tab(self, direction: int) -> None:
164
+ """Cycle tabs with left/right arrows (wraps at ends)."""
165
+ tabs = self.query_one(TabbedContent)
166
+ try:
167
+ idx = self.TAB_IDS.index(tabs.active)
168
+ except ValueError:
169
+ idx = 0
170
+ tabs.active = self.TAB_IDS[(idx + direction) % len(self.TAB_IDS)]
171
+
172
+ # All content widgets are read-only — don't let them swallow keys meant
173
+ # for the app bindings (e.g. Log grabs pgup/pgdn, Scroll consumes arrows).
174
+ async def on_ready(self) -> None:
175
+ for widget_id in ("status", "stats", "activity", "details-text", "details-scroll"):
176
+ try:
177
+ w = self.query_one(f"#{widget_id}")
178
+ except Exception:
179
+ continue
180
+ w.can_focus = False
181
+
182
+ # ── Polling ───────────────────────────────────────────────────────────
183
+
184
+ async def _poll_loop(self) -> None:
185
+ try:
186
+ while True:
187
+ await asyncio.sleep(self.POLL_INTERVAL)
188
+ await self._refresh_once()
189
+ except asyncio.CancelledError:
190
+ pass
191
+
192
+ async def _refresh_once(self) -> None:
193
+ session = await self._fetch_session_json()
194
+ self._render_sync_tab(session)
195
+ details_text = await self._fetch_session_long()
196
+ self.query_one("#details-text", Static).update(details_text)
197
+
198
+ async def _fetch_session_json(self) -> dict | None:
199
+ stdout = await self._run_mutagen(
200
+ ["sync", "list", "--template", "{{json .}}", self.session_name],
201
+ )
202
+ if not stdout or stdout == "null":
203
+ return None
204
+ try:
205
+ data = json.loads(stdout)
206
+ except json.JSONDecodeError:
207
+ return None
208
+ if isinstance(data, list):
209
+ return data[0] if data else None
210
+ if isinstance(data, dict):
211
+ return data
212
+ return None
213
+
214
+ async def _fetch_session_long(self) -> str:
215
+ stdout = await self._run_mutagen(
216
+ ["sync", "list", "--long", self.session_name],
217
+ )
218
+ return stdout or "(no data)"
219
+
220
+ async def _run_mutagen(self, args: list[str]) -> str:
221
+ try:
222
+ proc = await asyncio.create_subprocess_exec(
223
+ "mutagen",
224
+ *args,
225
+ # Detach from the controlling tty — otherwise each spawn races
226
+ # textual's driver for stdin and leaks raw mouse/key escape
227
+ # sequences into the rendered screen.
228
+ stdin=asyncio.subprocess.DEVNULL,
229
+ stdout=asyncio.subprocess.PIPE,
230
+ stderr=asyncio.subprocess.PIPE,
231
+ env=self._env,
232
+ start_new_session=True,
233
+ )
234
+ stdout, _ = await proc.communicate()
235
+ except (FileNotFoundError, OSError) as exc:
236
+ return f"(mutagen unavailable: {exc})"
237
+ if proc.returncode != 0:
238
+ return ""
239
+ return stdout.decode("utf-8", errors="replace").strip()
240
+
241
+ # ── Render: Sync tab ──────────────────────────────────────────────────
242
+
243
+ def _render_sync_tab(self, session: dict | None) -> None:
244
+ status_w = self.query_one("#status", Static)
245
+ stats_w = self.query_one("#stats", Static)
246
+ activity = self.query_one("#activity", Log)
247
+
248
+ pill = _state_pill(session)
249
+ alpha = (session or {}).get("alpha") or {}
250
+ beta = (session or {}).get("beta") or {}
251
+
252
+ alpha_url = alpha.get("path") or "?"
253
+ beta_host = beta.get("host")
254
+ beta_path = beta.get("path") or "?"
255
+ beta_url = f"{beta.get('user','')}@{beta_host}:{beta_path}" if beta_host else beta_path
256
+
257
+ last_error = (session or {}).get("lastError") or ""
258
+ status_lines = [
259
+ pill,
260
+ f"[dim]Agent:[/dim] {self.config.agent_name} [dim]@[/dim] {self.config.platform_url}",
261
+ f"[dim]Local:[/dim] {alpha_url}",
262
+ f"[dim]Remote:[/dim] {beta_url}",
263
+ ]
264
+ if last_error:
265
+ status_lines.append(f"[red]{last_error}[/red]")
266
+ status_w.update("\n".join(status_lines))
267
+
268
+ files = int(alpha.get("files") or 0)
269
+ dirs = int(alpha.get("directories") or 0)
270
+ size = int(alpha.get("totalFileSize") or 0)
271
+ cycles = int((session or {}).get("successfulCycles") or 0)
272
+ stats_w.update(
273
+ f"[bold]{files}[/bold] files · [bold]{dirs}[/bold] dirs · "
274
+ f"[bold]{_fmt_size(size)}[/bold] "
275
+ f"Successful cycles: [bold]{cycles}[/bold]"
276
+ )
277
+
278
+ self._emit_events(activity, session)
279
+
280
+ def _emit_events(self, log: Log, session: dict | None) -> None:
281
+ now = datetime.now().strftime("%H:%M:%S")
282
+
283
+ def line(msg: str) -> None:
284
+ log.write_line(f"{now} {msg}")
285
+
286
+ if session is None:
287
+ if self._prev is not None:
288
+ line("session disappeared from Mutagen daemon")
289
+ self._prev = None
290
+ return
291
+
292
+ if self._prev is None:
293
+ a_conn = bool((session.get("alpha") or {}).get("connected"))
294
+ b_conn = bool((session.get("beta") or {}).get("connected"))
295
+ if a_conn and b_conn:
296
+ line("sync attached — both endpoints connected")
297
+ else:
298
+ line("sync attached — waiting for endpoints to connect")
299
+ self._prev = session
300
+ return
301
+
302
+ prev = self._prev
303
+ self._prev = session
304
+
305
+ prev_status = (prev.get("status") or "").lower()
306
+ cur_status = (session.get("status") or "").lower()
307
+ if prev_status != cur_status:
308
+ line(f"status: {prev_status or '-'} → {cur_status or '-'}")
309
+
310
+ for side, label in (("alpha", "local"), ("beta", "remote")):
311
+ prev_conn = bool((prev.get(side) or {}).get("connected"))
312
+ cur_conn = bool((session.get(side) or {}).get("connected"))
313
+ if prev_conn != cur_conn:
314
+ line(f"{label} endpoint: {'connected' if cur_conn else 'disconnected'}")
315
+
316
+ prev_files = int((prev.get(side) or {}).get("files") or 0)
317
+ cur_files = int((session.get(side) or {}).get("files") or 0)
318
+ if cur_files != prev_files:
319
+ delta = cur_files - prev_files
320
+ sign = "+" if delta > 0 else ""
321
+ line(f"{label} file count: {prev_files} → {cur_files} ({sign}{delta})")
322
+
323
+ prev_cycles = int(prev.get("successfulCycles") or 0)
324
+ cur_cycles = int(session.get("successfulCycles") or 0)
325
+ if cur_cycles > prev_cycles:
326
+ line(f"completed sync cycle #{cur_cycles}")
327
+
328
+ prev_err = prev.get("lastError") or ""
329
+ cur_err = session.get("lastError") or ""
330
+ if cur_err and cur_err != prev_err:
331
+ line(f"[red]error:[/red] {cur_err}")
332
+ elif prev_err and not cur_err:
333
+ line("error cleared")
334
+
335
+ prev_paused = bool(prev.get("paused"))
336
+ cur_paused = bool(session.get("paused"))
337
+ if prev_paused != cur_paused:
338
+ line("session paused" if cur_paused else "session resumed")
339
+
340
+
341
+ def run_tui(
342
+ config: CinnaConfig,
343
+ session_name: str,
344
+ mutagen_env: dict[str, str],
345
+ ) -> int:
346
+ """Start the TUI app in the current terminal. Returns on user quit."""
347
+ # Suppress textual's INFO logging to keep our logger output clean in the
348
+ # (unlikely) event the user has DEBUG on.
349
+ logging.getLogger("textual").setLevel(logging.WARNING)
350
+ app = SyncApp(config, session_name, mutagen_env)
351
+ app.run()
352
+ return 0