@grifhinz/logics-manager 2.3.2 → 2.4.0

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.
@@ -16,13 +16,21 @@
16
16
  <body class="viewer-shell">
17
17
  <header class="viewer-topbar" aria-label="Viewer status">
18
18
  <div>
19
- <div class="viewer-topbar__title">Logics Viewer</div>
19
+ <div class="viewer-topbar__identity">
20
+ <div class="viewer-topbar__title">Logics Viewer</div>
21
+ <span class="viewer-topbar__repo" id="viewer-repo-pill" title="">repository</span>
22
+ </div>
20
23
  <div class="viewer-topbar__meta" id="viewer-meta">Read-only local viewer</div>
21
24
  </div>
22
25
  <div class="viewer-topbar__actions">
26
+ <label class="viewer-auto-refresh" title="Toggle automatic refresh">
27
+ <input id="viewer-auto-refresh" type="checkbox" checked />
28
+ <span>Auto</span>
29
+ </label>
30
+ <button class="btn" data-action="refresh" type="button" title="Refresh viewer data">Refresh</button>
31
+ <button class="btn" id="viewer-git" type="button" title="Show Git status">Git</button>
23
32
  <button class="btn" id="viewer-insights" type="button" title="Show corpus insights">Insights</button>
24
33
  <button class="btn" id="viewer-health" type="button" title="Show lint and audit health">Health</button>
25
- <button class="btn" data-action="refresh" type="button" title="Refresh viewer data">Refresh</button>
26
34
  </div>
27
35
  </header>
28
36
 
@@ -34,28 +42,6 @@
34
42
  <path d="M4 6h16l-6 7v5l-4 2v-7z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />
35
43
  </svg>
36
44
  </button>
37
- <div class="toolbar__tools">
38
- <button class="toolbar__filter" id="tools-toggle" aria-label="Open tools menu" aria-haspopup="menu" aria-expanded="false" aria-controls="tools-panel" title="Open tools menu">
39
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
40
- <rect x="5" y="6" width="14" height="2" rx="1" fill="currentColor" />
41
- <rect x="5" y="11" width="14" height="2" rx="1" fill="currentColor" />
42
- <rect x="5" y="16" width="14" height="2" rx="1" fill="currentColor" />
43
- </svg>
44
- </button>
45
- <div class="tools-panel" id="tools-panel" aria-hidden="true" role="menu">
46
- <div class="tools-panel__header">
47
- <div class="tools-panel__header-label">Tools</div>
48
- <button class="tools-panel__close" type="button" data-tools-panel-close aria-label="Close tools menu" title="Close tools menu">x</button>
49
- </div>
50
- <div class="tools-panel__section" data-tools-section="recommended">
51
- <div class="tools-panel__section-label">Read-only</div>
52
- <div class="tools-panel__section-body" data-tools-body="recommended">
53
- <button class="tools-panel__item" type="button" role="menuitem" data-action="refresh" title="Refresh viewer data">Refresh</button>
54
- <button class="tools-panel__item" type="button" role="menuitem" data-action="about" title="Open project page">About</button>
55
- </div>
56
- </div>
57
- </div>
58
- </div>
59
45
  <button class="toolbar__filter toolbar__filter--view" data-action="toggle-view-mode" aria-label="Switch display mode" title="Switch display mode">
60
46
  <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
61
47
  <rect x="4" y="5" width="6" height="6" rx="1.5" fill="none" stroke="currentColor" stroke-width="2" />
@@ -67,11 +53,11 @@
67
53
  <button class="toolbar__filter" id="activity-toggle" aria-label="Toggle activity" aria-pressed="false" title="Toggle activity">
68
54
  <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M4 12h4l2.2-5 3.6 10 2.2-5H20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>
69
55
  </button>
70
- <button class="toolbar__filter" id="attention-toggle" aria-label="Show attention required" aria-pressed="false" title="Show attention required">
71
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 4l8 14H4z" fill="none" stroke="currentColor" stroke-width="2" /><path d="M12 9v4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /><circle cx="12" cy="17" r="1" fill="currentColor" /></svg>
56
+ <button class="toolbar__filter" id="activity-clear" aria-label="Clear local activity history" title="Clear local activity history">
57
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 7h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /><path d="M9 7V5h6v2" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /><path d="M8 10v8m4-8v8m4-8v8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></svg>
72
58
  </button>
73
- <button class="toolbar__filter" id="header-logics-insights" aria-label="Open corpus insights" title="Open corpus insights">
74
- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2" /><path d="M12 10.5v6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /><circle cx="12" cy="7.5" r="1" fill="currentColor" /></svg>
59
+ <button class="toolbar__filter" id="attention-toggle" aria-label="Show blocked, orphaned, unprocessed, or inconsistent items" aria-pressed="false" title="Show blocked, orphaned, unprocessed, or inconsistent items">
60
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 4l8 14H4z" fill="none" stroke="currentColor" stroke-width="2" /><path d="M12 9v4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /><circle cx="12" cy="17" r="1" fill="currentColor" /></svg>
75
61
  </button>
76
62
  </div>
77
63
  <div class="toolbar__search">
@@ -16,6 +16,27 @@
16
16
  line-height: 1.25;
17
17
  }
18
18
 
19
+ .viewer-topbar__identity {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 8px;
23
+ min-width: 0;
24
+ }
25
+
26
+ .viewer-topbar__repo {
27
+ max-width: 220px;
28
+ overflow: hidden;
29
+ text-overflow: ellipsis;
30
+ white-space: nowrap;
31
+ border: 1px solid var(--vscode-panel-border, #333333);
32
+ border-radius: 999px;
33
+ padding: 2px 8px;
34
+ background: var(--vscode-badge-background, #4d4d4d);
35
+ color: var(--vscode-badge-foreground, #ffffff);
36
+ font-size: 11px;
37
+ line-height: 1.3;
38
+ }
39
+
19
40
  .viewer-topbar__meta {
20
41
  margin-top: 2px;
21
42
  font-size: 12px;
@@ -30,6 +51,24 @@
30
51
  flex: 0 0 auto;
31
52
  }
32
53
 
54
+ .viewer-auto-refresh {
55
+ display: inline-flex;
56
+ align-items: center;
57
+ gap: 6px;
58
+ min-height: 28px;
59
+ border: 1px solid var(--vscode-button-border, var(--vscode-panel-border, #333333));
60
+ border-radius: 4px;
61
+ padding: 0 8px;
62
+ background: var(--vscode-button-secondaryBackground, transparent);
63
+ color: var(--vscode-button-secondaryForeground, var(--vscode-foreground, #d4d4d4));
64
+ font-size: 12px;
65
+ user-select: none;
66
+ }
67
+
68
+ .viewer-auto-refresh input {
69
+ margin: 0;
70
+ }
71
+
33
72
  .viewer-update {
34
73
  flex: 0 0 auto;
35
74
  display: flex;
@@ -220,6 +259,103 @@
220
259
  background: var(--vscode-textCodeBlock-background, #111111);
221
260
  }
222
261
 
262
+ .viewer-insights__rows,
263
+ .viewer-git__files {
264
+ display: grid;
265
+ gap: 6px;
266
+ margin: 10px 0 0;
267
+ padding: 0;
268
+ list-style: none;
269
+ }
270
+
271
+ .viewer-insights__row {
272
+ display: flex;
273
+ align-items: center;
274
+ justify-content: space-between;
275
+ gap: 10px;
276
+ min-width: 0;
277
+ padding: 7px 9px;
278
+ border: 1px solid var(--vscode-panel-border, #333333);
279
+ border-radius: 6px;
280
+ background: color-mix(in srgb, var(--vscode-editorWidget-background, #202020) 84%, transparent);
281
+ font-size: 12px;
282
+ }
283
+
284
+ .viewer-insights__row[hidden] {
285
+ display: none !important;
286
+ }
287
+
288
+ .viewer-insights__doc,
289
+ .viewer-insights__action,
290
+ .viewer-insights__reveal,
291
+ .viewer-health__path {
292
+ min-width: 0;
293
+ border: 0;
294
+ padding: 0;
295
+ background: transparent;
296
+ color: var(--vscode-textLink-foreground, #4ea1ff);
297
+ font: inherit;
298
+ text-align: left;
299
+ overflow-wrap: anywhere;
300
+ cursor: pointer;
301
+ }
302
+
303
+ .viewer-insights__doc:hover,
304
+ .viewer-insights__action:hover,
305
+ .viewer-insights__reveal:hover,
306
+ .viewer-health__path:hover {
307
+ text-decoration: underline;
308
+ }
309
+
310
+ .viewer-git,
311
+ .viewer-insights,
312
+ .viewer-health {
313
+ display: grid;
314
+ gap: 14px;
315
+ }
316
+
317
+ .viewer-git__summary,
318
+ .viewer-insights__summary,
319
+ .viewer-health__summary {
320
+ display: grid;
321
+ grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
322
+ gap: 8px;
323
+ }
324
+
325
+ .viewer-git__section h2 {
326
+ margin-bottom: 8px;
327
+ }
328
+
329
+ .viewer-git__files li {
330
+ min-width: 0;
331
+ padding: 7px 9px;
332
+ border: 1px solid var(--vscode-panel-border, #333333);
333
+ border-radius: 6px;
334
+ background: var(--vscode-editorWidget-background, #202020);
335
+ overflow-wrap: anywhere;
336
+ }
337
+
338
+ .viewer-git__commit,
339
+ .viewer-git__state {
340
+ margin: 0;
341
+ overflow-wrap: anywhere;
342
+ }
343
+
344
+ @media (max-width: 700px) {
345
+ .viewer-topbar {
346
+ align-items: stretch;
347
+ flex-direction: column;
348
+ }
349
+
350
+ .viewer-topbar__actions {
351
+ flex-wrap: wrap;
352
+ }
353
+
354
+ .viewer-document {
355
+ inset: 8px;
356
+ }
357
+ }
358
+
223
359
  .viewer-document__content a {
224
360
  color: var(--vscode-textLink-foreground, #4ea1ff);
225
361
  }
@@ -412,12 +548,17 @@
412
548
 
413
549
  .viewer-topbar__actions {
414
550
  width: 100%;
551
+ flex-wrap: wrap;
415
552
  }
416
553
 
417
554
  .viewer-topbar__actions .btn {
418
555
  flex: 1 1 0;
419
556
  }
420
557
 
558
+ .viewer-auto-refresh {
559
+ flex: 0 0 auto;
560
+ }
561
+
421
562
  .viewer-document {
422
563
  inset: 10px;
423
564
  }
@@ -5,6 +5,8 @@ import json
5
5
  import mimetypes
6
6
  import os
7
7
  import re
8
+ import shutil
9
+ import socket
8
10
  import subprocess
9
11
  import sys
10
12
  import webbrowser
@@ -40,9 +42,15 @@ DOC_FAMILIES = (
40
42
 
41
43
  STAGE_ORDER = {family.stage: index for index, family in enumerate(DOC_FAMILIES)}
42
44
  REPO_ROOT = Path(__file__).resolve().parents[1]
45
+ PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
43
46
  VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
47
+ if not (VIEWER_ROOT / "index.html").is_file():
48
+ VIEWER_ROOT = PACKAGE_VIEWER_ASSETS_ROOT / "viewer"
44
49
  SHARED_MEDIA_ROOT = REPO_ROOT / "clients" / "shared-web" / "media"
50
+ if not SHARED_MEDIA_ROOT.is_dir():
51
+ SHARED_MEDIA_ROOT = PACKAGE_VIEWER_ASSETS_ROOT / "media"
45
52
  DIST_VENDOR_ROOT = REPO_ROOT / "dist" / "vendor"
53
+ PACKAGE_VENDOR_ROOT = PACKAGE_VIEWER_ASSETS_ROOT / "vendor"
46
54
  NODE_MERMAID_ROOT = REPO_ROOT / "node_modules" / "mermaid" / "dist"
47
55
 
48
56
 
@@ -322,9 +330,16 @@ def collect_viewer_items(repo_root: Path) -> list[dict[str, Any]]:
322
330
  return items
323
331
 
324
332
 
325
- def viewer_data_payload(repo_root: Path, selected_id: str | None = None) -> dict[str, Any]:
333
+ def viewer_data_payload(
334
+ repo_root: Path,
335
+ selected_id: str | None = None,
336
+ *,
337
+ auto_refresh_interval_seconds: int = 60,
338
+ ) -> dict[str, Any]:
326
339
  return {
327
340
  "root": str(repo_root.resolve()),
341
+ "repoName": repo_root.resolve().name,
342
+ "autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
328
343
  "items": collect_viewer_items(repo_root),
329
344
  "updateInfo": get_update_info(_current_version()).to_payload(),
330
345
  "selectedId": selected_id,
@@ -378,13 +393,122 @@ def _system_editor_command(path: Path) -> list[str]:
378
393
  return ["xdg-open", str(path)]
379
394
 
380
395
 
396
+ def _run_read_only_git(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
397
+ command = ["git", *args]
398
+ git_runner = runner or subprocess.run
399
+ return git_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
400
+
401
+
402
+ def _sanitize_git_ref(value: str) -> str:
403
+ ref = value.strip()
404
+ ref = re.sub(r"://[^/@\s]+@", "://", ref)
405
+ ref = re.sub(r"^[^/@\s]+@", "", ref)
406
+ return ref[:200]
407
+
408
+
409
+ def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
410
+ if not line or line.startswith("## "):
411
+ return None
412
+ if line.startswith("?? "):
413
+ return "untracked", {"path": line[3:].strip()}
414
+ if len(line) < 4:
415
+ return None
416
+ staged = line[0]
417
+ worktree = line[1]
418
+ raw_path = line[3:].strip()
419
+ if " -> " in raw_path:
420
+ before, after = raw_path.split(" -> ", 1)
421
+ return "renamed", {"path": after.strip(), "from": before.strip()}
422
+ if staged == "R":
423
+ return "renamed", {"path": raw_path}
424
+ if staged not in {" ", "?", "!"}:
425
+ return "staged", {"path": raw_path, "code": staged}
426
+ if worktree == "D":
427
+ return "deleted", {"path": raw_path, "code": worktree}
428
+ if worktree not in {" ", "?", "!"}:
429
+ return "modified", {"path": raw_path, "code": worktree}
430
+ return None
431
+
432
+
433
+ def _parse_git_branch_line(line: str) -> dict[str, Any]:
434
+ branch = line[3:].strip() if line.startswith("## ") else ""
435
+ tracking = ""
436
+ ahead = 0
437
+ behind = 0
438
+ if "..." in branch:
439
+ branch, tracking_part = branch.split("...", 1)
440
+ if " [" in tracking_part:
441
+ tracking, details = tracking_part.split(" [", 1)
442
+ for detail in details.rstrip("]").split(", "):
443
+ if detail.startswith("ahead "):
444
+ ahead = int(detail.removeprefix("ahead ") or "0")
445
+ if detail.startswith("behind "):
446
+ behind = int(detail.removeprefix("behind ") or "0")
447
+ else:
448
+ tracking = tracking_part
449
+ return {
450
+ "branch": _sanitize_git_ref(branch or "HEAD"),
451
+ "tracking": _sanitize_git_ref(tracking),
452
+ "ahead": ahead,
453
+ "behind": behind,
454
+ }
455
+
456
+
457
+ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
458
+ git_which = which or shutil.which
459
+ if not git_which("git"):
460
+ return {"state": "unavailable", "message": "Git is not available on PATH."}
461
+ try:
462
+ inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
463
+ except (OSError, subprocess.SubprocessError) as exc:
464
+ return {"state": "error", "message": f"Unable to run Git status: {exc}"}
465
+ if inside.returncode != 0 or inside.stdout.strip().lower() != "true":
466
+ return {"state": "not-repository", "message": "This folder is not inside a Git worktree."}
467
+
468
+ try:
469
+ status = _run_read_only_git(repo_root, ["status", "--porcelain=v1", "-b"], runner=runner)
470
+ commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
471
+ except (OSError, subprocess.SubprocessError) as exc:
472
+ return {"state": "error", "message": f"Unable to collect Git status: {exc}"}
473
+ if status.returncode != 0:
474
+ message = (status.stderr or status.stdout or "Git status failed.").strip().splitlines()[0]
475
+ return {"state": "error", "message": message}
476
+
477
+ lines = status.stdout.splitlines()
478
+ branch_info = _parse_git_branch_line(lines[0]) if lines else {"branch": "HEAD", "tracking": "", "ahead": 0, "behind": 0}
479
+ groups: dict[str, list[dict[str, str]]] = {key: [] for key in ("staged", "modified", "deleted", "renamed", "untracked")}
480
+ for line in lines[1:]:
481
+ classified = _classify_porcelain_entry(line)
482
+ if classified:
483
+ group, entry = classified
484
+ groups[group].append(entry)
485
+ counts = {key: len(value) for key, value in groups.items()}
486
+ dirty = any(counts.values())
487
+ return {
488
+ "state": "ok",
489
+ **branch_info,
490
+ "clean": not dirty,
491
+ "dirty": dirty,
492
+ "counts": counts,
493
+ "groups": groups,
494
+ "latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
495
+ }
496
+
497
+
381
498
  def _json_bytes(payload: Any) -> bytes:
382
499
  return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
383
500
 
384
501
 
385
502
  class LogicsViewerServer(ThreadingHTTPServer):
386
- def __init__(self, server_address: tuple[str, int], repo_root: Path):
503
+ def __init__(
504
+ self,
505
+ server_address: tuple[str, int],
506
+ repo_root: Path,
507
+ *,
508
+ auto_refresh_interval_seconds: int = 60,
509
+ ):
387
510
  self.repo_root = repo_root.resolve()
511
+ self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
388
512
  super().__init__(server_address, LogicsViewerRequestHandler)
389
513
 
390
514
 
@@ -435,6 +559,8 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
435
559
  vendor_path = DIST_VENDOR_ROOT / "mermaid.min.js"
436
560
  if not vendor_path.is_file():
437
561
  vendor_path = NODE_MERMAID_ROOT / "mermaid.min.js"
562
+ if not vendor_path.is_file():
563
+ vendor_path = PACKAGE_VENDOR_ROOT / "mermaid.min.js"
438
564
  self._serve_file(vendor_path)
439
565
  return
440
566
  if route.startswith("/media/"):
@@ -445,7 +571,15 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
445
571
  self._serve_file(media_path)
446
572
  return
447
573
  if route == "/api/items":
448
- self._send_json({"ok": True, "payload": viewer_data_payload(self.server.repo_root)})
574
+ self._send_json(
575
+ {
576
+ "ok": True,
577
+ "payload": viewer_data_payload(
578
+ self.server.repo_root,
579
+ auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
580
+ ),
581
+ }
582
+ )
449
583
  return
450
584
  if route == "/api/doc":
451
585
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
@@ -460,12 +594,23 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
460
594
  if route == "/api/audit":
461
595
  self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
462
596
  return
597
+ if route == "/api/git-status":
598
+ self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
599
+ return
463
600
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
464
601
 
465
602
  def do_POST(self) -> None:
466
603
  parsed = urlparse(self.path)
467
604
  if parsed.path == "/api/refresh":
468
- self._send_json({"ok": True, "payload": viewer_data_payload(self.server.repo_root)})
605
+ self._send_json(
606
+ {
607
+ "ok": True,
608
+ "payload": viewer_data_payload(
609
+ self.server.repo_root,
610
+ auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
611
+ ),
612
+ }
613
+ )
469
614
  return
470
615
  if parsed.path == "/api/edit":
471
616
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
@@ -479,19 +624,52 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
479
624
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
480
625
 
481
626
 
482
- def create_viewer_server(repo_root: Path, host: str = "127.0.0.1", port: int = 8765) -> LogicsViewerServer:
483
- return LogicsViewerServer((host, port), repo_root)
627
+ def create_viewer_server(
628
+ repo_root: Path,
629
+ host: str = "127.0.0.1",
630
+ port: int = 8765,
631
+ *,
632
+ auto_refresh_interval_seconds: int = 60,
633
+ ) -> LogicsViewerServer:
634
+ return LogicsViewerServer(
635
+ (host, port),
636
+ repo_root,
637
+ auto_refresh_interval_seconds=auto_refresh_interval_seconds,
638
+ )
484
639
 
485
640
 
486
- def render_start_status(url: str, repo_root: Path, *, focus: str | None = None) -> str:
641
+ def _network_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str | None:
642
+ if host not in {"0.0.0.0", "::", ""}:
643
+ return None
644
+ try:
645
+ candidate = socket.gethostbyname(socket.gethostname())
646
+ except OSError:
647
+ return None
648
+ if not candidate or candidate.startswith("127."):
649
+ return None
650
+ return build_viewer_url(candidate, port, focus=focus, read=read)
651
+
652
+
653
+ def render_start_status(
654
+ url: str,
655
+ repo_root: Path,
656
+ *,
657
+ focus: str | None = None,
658
+ network_url: str | None = None,
659
+ bind_host: str = "localhost",
660
+ auto_refresh_interval_seconds: int = 60,
661
+ ) -> str:
487
662
  lines = [
488
663
  "Logics viewer running:",
489
- url,
664
+ f"Local: {url}",
490
665
  "",
491
666
  f"Repo: {repo_root.name}",
492
667
  "Mode: read-only",
493
- "Bind: localhost",
668
+ f"Bind: {bind_host}",
669
+ f"Auto refresh: {auto_refresh_interval_seconds}s",
494
670
  ]
671
+ if network_url:
672
+ lines.insert(2, f"Network: {network_url}")
495
673
  if focus:
496
674
  lines.append(f"Focus: {focus}")
497
675
  return "\n".join(lines)
@@ -501,6 +679,12 @@ def build_parser() -> argparse.ArgumentParser:
501
679
  parser = argparse.ArgumentParser(prog="logics-manager view", description="Start the local read-only Logics browser viewer.")
502
680
  parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
503
681
  parser.add_argument("--port", type=int, default=8765, help="Bind port. Use 0 to select an available port.")
682
+ parser.add_argument(
683
+ "--refresh-interval",
684
+ type=int,
685
+ default=60,
686
+ help="Automatic refresh interval in seconds. Defaults to 60; positive shorter intervals are allowed.",
687
+ )
504
688
  parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
505
689
  parser.add_argument("--read", action="store_true", help="Open the focused item in the read preview. Requires --focus.")
506
690
  parser.add_argument("--open", action="store_true", help="Open the viewer in the default browser.")
@@ -511,16 +695,34 @@ def build_parser() -> argparse.ArgumentParser:
511
695
  def main(argv: list[str]) -> int:
512
696
  args = build_parser().parse_args(argv)
513
697
  repo_root = find_repo_root(Path.cwd())
698
+ if args.refresh_interval <= 0:
699
+ raise SystemExit("--refresh-interval must be a positive number of seconds.")
514
700
  if args.read and not args.focus:
515
701
  raise SystemExit("--read requires --focus.")
516
702
  try:
517
703
  focus = normalize_viewer_focus_target(repo_root, args.focus) if args.focus else None
518
704
  except ValueError as exc:
519
705
  raise SystemExit(str(exc)) from exc
520
- server = create_viewer_server(repo_root, host=args.host, port=args.port)
706
+ server = create_viewer_server(
707
+ repo_root,
708
+ host=args.host,
709
+ port=args.port,
710
+ auto_refresh_interval_seconds=args.refresh_interval,
711
+ )
521
712
  host, port = server.server_address[:2]
522
713
  url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
523
- print(render_start_status(url, repo_root, focus=focus), flush=True)
714
+ network_url = _network_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
715
+ print(
716
+ render_start_status(
717
+ url,
718
+ repo_root,
719
+ focus=focus,
720
+ network_url=network_url,
721
+ bind_host=str(host),
722
+ auto_refresh_interval_seconds=args.refresh_interval,
723
+ ),
724
+ flush=True,
725
+ )
524
726
  if args.open and not args.no_open:
525
727
  webbrowser.open(url)
526
728
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@grifhinz/logics-manager",
3
3
  "displayName": "Logics Orchestrator",
4
4
  "description": "Visual orchestration for Logics workflows inside VS Code.",
5
- "version": "2.3.2",
5
+ "version": "2.4.0",
6
6
  "publisher": "cdx-logics",
7
7
  "icon": "clients/shared-web/media/icon.png",
8
8
  "repository": {
@@ -127,6 +127,7 @@
127
127
  "test:coverage:media": "node scripts/run-plugin-coverage.mjs media",
128
128
  "test:coverage": "npm run test:coverage:src && npm run test:coverage:media",
129
129
  "test:smoke": "node tests/run_extension_smoke_checks.mjs",
130
+ "test:viewer-smoke": "node tests/run_local_viewer_visual_smoke.mjs",
130
131
  "test:npm-cli": "node scripts/npm/logics-manager.mjs --help",
131
132
  "test:lifecycle": "node tests/run_plugin_lifecycle_checks.mjs",
132
133
  "test:watch": "vitest",
@@ -155,14 +156,15 @@
155
156
  "@types/vscode": "^1.86.0",
156
157
  "@typescript-eslint/eslint-plugin": "^8.58.1",
157
158
  "@typescript-eslint/parser": "^8.58.1",
158
- "@vscode/vsce": "^3.9.1",
159
159
  "@vitest/coverage-v8": "^4.1.2",
160
+ "@vscode/vsce": "^3.9.1",
160
161
  "esbuild": "^0.25.10",
161
162
  "eslint": "^10.2.0",
162
163
  "jsdom": "^25.0.1",
163
164
  "mermaid": "^11.14.0",
164
165
  "typescript": "^5.3.3",
165
166
  "vitest": "^4.1.2",
167
+ "ws": "8.21.0",
166
168
  "yaml": "^2.8.3",
167
169
  "yauzl": "^3.2.0"
168
170
  },
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "logics-manager"
7
- version = "2.3.2"
7
+ version = "2.4.0"
8
8
  description = "Canonical Logics CLI"
9
9
  requires-python = ">=3.10"
10
10
 
@@ -12,4 +12,19 @@ requires-python = ">=3.10"
12
12
  logics-manager = "logics_manager.cli:main"
13
13
 
14
14
  [tool.setuptools]
15
- packages = ["logics_manager"]
15
+ packages = [
16
+ "logics_manager",
17
+ "logics_manager.viewer_assets",
18
+ "logics_manager.viewer_assets.media",
19
+ "logics_manager.viewer_assets.media.css",
20
+ "logics_manager.viewer_assets.vendor",
21
+ "logics_manager.viewer_assets.viewer",
22
+ ]
23
+
24
+ [tool.setuptools.package-data]
25
+ logics_manager = [
26
+ "viewer_assets/media/*",
27
+ "viewer_assets/media/css/*",
28
+ "viewer_assets/vendor/*",
29
+ "viewer_assets/viewer/*",
30
+ ]