bugcap 0.2.2__tar.gz → 0.3.0__tar.gz

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.
Files changed (84) hide show
  1. {bugcap-0.2.2/src/bugcap.egg-info → bugcap-0.3.0}/PKG-INFO +6 -6
  2. {bugcap-0.2.2 → bugcap-0.3.0}/README.md +5 -5
  3. {bugcap-0.2.2 → bugcap-0.3.0}/pyproject.toml +1 -1
  4. bugcap-0.3.0/src/bugcap/__init__.py +1 -0
  5. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/agent_api.py +2 -1
  6. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/cli.py +48 -5
  7. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/api.py +4 -2
  8. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/static/app.js +2 -1
  9. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/live.py +87 -29
  10. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/mcp_server.py +2 -2
  11. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/refs.py +67 -10
  12. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/service.py +36 -10
  13. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/store.py +10 -0
  14. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/sync.py +26 -6
  15. {bugcap-0.2.2 → bugcap-0.3.0/src/bugcap.egg-info}/PKG-INFO +6 -6
  16. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/SOURCES.txt +2 -0
  17. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_agent_api.py +1 -1
  18. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_attach.py +8 -8
  19. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_media.py +7 -7
  20. bugcap-0.3.0/tests/test_cli_no_image_delete.py +95 -0
  21. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_mutations.py +1 -1
  22. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_github_media_sync.py +18 -2
  23. bugcap-0.3.0/tests/test_image_refs.py +82 -0
  24. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_refs_validate_rewrite.py +1 -1
  25. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_service_media.py +1 -1
  26. bugcap-0.2.2/src/bugcap/__init__.py +0 -1
  27. {bugcap-0.2.2 → bugcap-0.3.0}/LICENSE +0 -0
  28. {bugcap-0.2.2 → bugcap-0.3.0}/setup.cfg +0 -0
  29. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/backends.py +0 -0
  30. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/capture.py +0 -0
  31. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/config.py +0 -0
  32. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/__init__.py +0 -0
  33. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/media.py +0 -0
  34. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/server.py +0 -0
  35. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/static/app.css +0 -0
  36. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/dashboard/static/index.html +0 -0
  37. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/drafts.py +0 -0
  38. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/errors.py +0 -0
  39. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/ghcli.py +0 -0
  40. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/ingest.py +0 -0
  41. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/live_session.py +0 -0
  42. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/logs.py +0 -0
  43. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/paths.py +0 -0
  44. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/recorder.py +0 -0
  45. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/repo.py +0 -0
  46. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/tomlio.py +0 -0
  47. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap/validation.py +0 -0
  48. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/dependency_links.txt +0 -0
  49. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/entry_points.txt +0 -0
  50. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/requires.txt +0 -0
  51. {bugcap-0.2.2 → bugcap-0.3.0}/src/bugcap.egg-info/top_level.txt +0 -0
  52. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_backends.py +0 -0
  53. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_capture.py +0 -0
  54. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_dashboard.py +0 -0
  55. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_init_capture_list.py +0 -0
  56. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_live_headless.py +0 -0
  57. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_record.py +0 -0
  58. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_setup.py +0 -0
  59. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_cli_triage.py +0 -0
  60. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_api.py +0 -0
  61. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_media.py +0 -0
  62. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_security.py +0 -0
  63. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_dashboard_ui_static.py +0 -0
  64. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_drafts.py +0 -0
  65. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_ghcli.py +0 -0
  66. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_github_pull.py +0 -0
  67. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_github_sync.py +0 -0
  68. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_ingest.py +0 -0
  69. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_ingest_url.py +0 -0
  70. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_live_session.py +0 -0
  71. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_mcp.py +0 -0
  72. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_mcp_logging.py +0 -0
  73. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_paths.py +0 -0
  74. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_recorder_argv.py +0 -0
  75. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_recorder_devices.py +0 -0
  76. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_recorder_media_real.py +0 -0
  77. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_recorder_run.py +0 -0
  78. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_refs.py +0 -0
  79. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_regression_output.py +0 -0
  80. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_repo_config.py +0 -0
  81. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_repo_values.py +0 -0
  82. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_service_refs.py +0 -0
  83. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_store_migration.py +0 -0
  84. {bugcap-0.2.2 → bugcap-0.3.0}/tests/test_sync_consent.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bugcap
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule.
5
5
  Author: Konrad Tagnon Amen ALAHASSA
6
6
  License: MIT
@@ -68,8 +68,8 @@ bugcap keeps the whole loop in one place:
68
68
  ## Quick start
69
69
 
70
70
  ```bash
71
- # install (from GitHub until the PyPI release is live)
72
- pipx install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap'
71
+ # install from PyPI
72
+ pipx install 'bugcap[mcp]'
73
73
 
74
74
  bugcap setup --yes # detects or installs a capture tool
75
75
  cd ~/code/myproject && bugcap init # scope reports to this repo
@@ -128,8 +128,8 @@ Requires Python 3.10 or newer. The core CLI uses only the standard library (plus
128
128
  Python 3.11). The MCP server is an optional extra.
129
129
 
130
130
  ```bash
131
- pipx install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap' # recommended
132
- uv tool install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap'
131
+ pipx install 'bugcap[mcp]' # recommended
132
+ uv tool install 'bugcap[mcp]'
133
133
  pip install -e '.[dev,mcp]' # development
134
134
  ```
135
135
 
@@ -146,7 +146,7 @@ pull and sync, MCP, and where data lives per OS.
146
146
 
147
147
  Notes on the more involved behaviour:
148
148
 
149
- - **`@` references.** `@1` or `@login-error` in a note points at an image. They are validated on save, and relabelling or removing a referenced image needs `--force`.
149
+ - **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
150
150
  - **Repo values** (`init`, `sync`, `config repo set`) are checked for format and, with `gh`, for existence. Changing the repo identity offers to move existing reports.
151
151
  - **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
152
152
 
@@ -52,8 +52,8 @@ bugcap keeps the whole loop in one place:
52
52
  ## Quick start
53
53
 
54
54
  ```bash
55
- # install (from GitHub until the PyPI release is live)
56
- pipx install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap'
55
+ # install from PyPI
56
+ pipx install 'bugcap[mcp]'
57
57
 
58
58
  bugcap setup --yes # detects or installs a capture tool
59
59
  cd ~/code/myproject && bugcap init # scope reports to this repo
@@ -112,8 +112,8 @@ Requires Python 3.10 or newer. The core CLI uses only the standard library (plus
112
112
  Python 3.11). The MCP server is an optional extra.
113
113
 
114
114
  ```bash
115
- pipx install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap' # recommended
116
- uv tool install 'bugcap[mcp] @ git+https://github.com/amenalahassa/bugcap'
115
+ pipx install 'bugcap[mcp]' # recommended
116
+ uv tool install 'bugcap[mcp]'
117
117
  pip install -e '.[dev,mcp]' # development
118
118
  ```
119
119
 
@@ -130,7 +130,7 @@ pull and sync, MCP, and where data lives per OS.
130
130
 
131
131
  Notes on the more involved behaviour:
132
132
 
133
- - **`@` references.** `@1` or `@login-error` in a note points at an image. They are validated on save, and relabelling or removing a referenced image needs `--force`.
133
+ - **References.** In a note, `@i1` is image 1, `@v2` a video, `@g3` an animated GIF, `@f4` a frame set, and `@login-error` a labelled item. `#3` points at report 3 (a missing one is kept as text with a warning). Media references are validated on save, and relabelling or removing a referenced item needs `--force`.
134
134
  - **Repo values** (`init`, `sync`, `config repo set`) are checked for format and, with `gh`, for existence. Changing the repo identity offers to move existing reports.
135
135
  - **Media sync** over `[sync] max_upload_mb` (default 25) is skipped with a warning.
136
136
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bugcap"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Local-first, agent-readable bug capture: annotated screenshots + notes, stored locally, synced to trackers on your own schedule."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -120,7 +120,8 @@ def update_notes(store: Store, id: int, notes: str) -> dict:
120
120
  except ServiceError as exc:
121
121
  return {"error": exc.message, **exc.as_dict()}
122
122
  view = service.get_report_view(store, id)
123
- return {"report_id": report.id, "notes": report.notes, "references": view.references}
123
+ warnings = service.report_ref_warnings(store, report.notes)
124
+ return {"report_id": report.id, "notes": report.notes, "references": view.references, "warnings": warnings}
124
125
 
125
126
 
126
127
  def _resolve_slug(explicit: Optional[str]) -> Optional[str]:
@@ -42,17 +42,26 @@ def print_ingest(added, rejected) -> None:
42
42
  """One line per input: `added #<idx> ...` on stdout, `skipped ...` on stderr."""
43
43
  for media in added:
44
44
  name = os.path.basename(media.path or "")
45
- print(f"added #{media.idx} {name} ({media.kind}, {human_size(media.size_bytes)})")
45
+ print(f"added {refs.media_token(media)} {name} ({media.kind}, {human_size(media.size_bytes)})")
46
46
  for item in rejected:
47
47
  print(f"skipped {item['source']}: {item['reason']}", file=sys.stderr)
48
48
 
49
49
 
50
50
  # --- capture / list / show ----------------------------------------------------
51
51
 
52
+ def _warn_report_refs(store: Store, notes) -> None:
53
+ """Soft warnings for `#N` references to reports that do not exist (never a failure)."""
54
+ for line in service.report_ref_warnings(store, notes or ""):
55
+ print(line, file=sys.stderr)
56
+
57
+
52
58
  def cmd_capture(args: argparse.Namespace) -> int:
53
59
  cfg = current_repo()
54
60
  captured: Optional[str] = None
55
- if not args.image:
61
+ if args.no_image and (args.image or args.label):
62
+ print("error: --no-image cannot be combined with --image or --label", file=sys.stderr)
63
+ return 2
64
+ if not args.image and not args.no_image:
56
65
  try:
57
66
  captured = str(capture.capture_screenshot())
58
67
  except CaptureError as exc:
@@ -89,6 +98,8 @@ def cmd_capture(args: argparse.Namespace) -> int:
89
98
  except ServiceError as exc:
90
99
  print_service_error(exc)
91
100
  return 1
101
+ if report is not None:
102
+ _warn_report_refs(store, report.notes)
92
103
 
93
104
  if report is None:
94
105
  print_ingest([], result.rejected)
@@ -103,6 +114,27 @@ def cmd_capture(args: argparse.Namespace) -> int:
103
114
  return 1 if result.rejected else 0
104
115
 
105
116
 
117
+ def cmd_delete(args: argparse.Namespace) -> int:
118
+ with Store() as store:
119
+ report = resolve_report(store, args.id)
120
+ if report is None:
121
+ return 1
122
+ if not args.yes:
123
+ if sys.stdin is None or not sys.stdin.isatty():
124
+ print("error: refusing to delete without confirmation; re-run with --yes", file=sys.stderr)
125
+ return 2
126
+ answer = input(
127
+ f"Delete report #{report.id} {report.title!r} and its {len(report.media)} image(s)? [y/N] "
128
+ ).strip().lower()
129
+ if answer != "y":
130
+ print("Nothing deleted.")
131
+ return 1
132
+ service.delete_report(store, args.id)
133
+
134
+ print(f"Deleted report #{report.id}: {report.title}")
135
+ return 0
136
+
137
+
106
138
  def cmd_list(args: argparse.Namespace) -> int:
107
139
  cfg = current_repo()
108
140
  scope = cfg.key if (cfg is not None and not args.all) else None
@@ -142,7 +174,7 @@ def cmd_show(args: argparse.Namespace) -> int:
142
174
  for m in detailed:
143
175
  where = m.path or f"({len(m.frames)} frames)"
144
176
  origin = f" (source: {m.source})" if m.source else ""
145
- print(f" #{m.idx} {m.kind:8s} {m.label or '-':12s} {where} {human_size(m.size_bytes)}{origin}")
177
+ print(f" {refs.media_token(m)} {m.kind:8s} {m.label or '-':12s} {where} {human_size(m.size_bytes)}{origin}")
146
178
  print(f" synced_refs:")
147
179
  if report.synced_refs:
148
180
  for tracker, ref in report.synced_refs.items():
@@ -440,6 +472,8 @@ def cmd_edit(args: argparse.Namespace) -> int:
440
472
  print(f"error: {exc}", file=sys.stderr)
441
473
  return 2
442
474
 
475
+ _warn_report_refs(store, report.notes)
476
+
443
477
  changed = []
444
478
  if args.title is not None:
445
479
  changed.append("title")
@@ -529,6 +563,7 @@ def cmd_attach(args: argparse.Namespace) -> int:
529
563
  print("warning: the image was attached but the note was not saved.", file=sys.stderr)
530
564
  return 1
531
565
  current = store.get(args.id)
566
+ _warn_report_refs(store, current.notes)
532
567
  if args.image:
533
568
  try:
534
569
  refs.validate_references(current.notes, current.media)
@@ -552,7 +587,7 @@ def cmd_images(args: argparse.Namespace) -> int:
552
587
  if args.action is None:
553
588
  for m in report.media:
554
589
  where = m.path or f"({len(m.frames)} frames)"
555
- print(f"#{m.idx} {m.label or '-':12s} {m.kind:8s} {human_size(m.size_bytes):>9s} {where}")
590
+ print(f"{refs.media_token(m)} {m.label or '-':12s} {m.kind:8s} {human_size(m.size_bytes):>9s} {where}")
556
591
  return 0
557
592
  try:
558
593
  if args.action == "relabel":
@@ -634,8 +669,9 @@ def cmd_record(args: argparse.Namespace) -> int:
634
669
  recorder.discard(result)
635
670
  print_service_error(exc)
636
671
  return 1
672
+ _warn_report_refs(store, report.notes)
637
673
  print(f"recorded {result.duration:.1f} s, {human_size(result.size_bytes)} ({result.kind}), stopped: {result.reason}")
638
- print(f"added #{media.idx} to report #{report.id}")
674
+ print(f"added {refs.media_token(media)} to report #{report.id}")
639
675
  return 0
640
676
 
641
677
 
@@ -828,8 +864,15 @@ def build_parser() -> argparse.ArgumentParser:
828
864
  p.add_argument("--image", action="append", metavar="SRC",
829
865
  help="Import an image (path, glob or http(s) URL) instead of launching a capture tool; repeatable.")
830
866
  p.add_argument("--label", action="append", help="Label for the --image at the same position (repeatable).")
867
+ p.add_argument("--no-image", dest="no_image", action="store_true",
868
+ help="Save a text-only report: no capture tool and no image.")
831
869
  p.set_defaults(func=cmd_capture)
832
870
 
871
+ p = sub.add_parser("delete", help="Permanently delete a report and its images (not synced GitHub issues).")
872
+ p.add_argument("id", type=int, help="Report id (see `bugcap list`).")
873
+ p.add_argument("--yes", action="store_true", help="Delete without the confirmation prompt.")
874
+ p.set_defaults(func=cmd_delete)
875
+
833
876
  p = sub.add_parser("list", help="List reports (current repo only, unless --all).")
834
877
  p.add_argument("--all", action="store_true", help="List reports from every repo.")
835
878
  p.set_defaults(func=cmd_list)
@@ -101,5 +101,7 @@ def set_notes(store, report_id: int, body: dict) -> dict:
101
101
  notes = body.get("notes")
102
102
  if not isinstance(notes, str):
103
103
  raise ServiceError("bad_query", "body must be {\"notes\": \"...\"}")
104
- service.set_notes(store, report_id, notes)
105
- return get_report(store, report_id)
104
+ report = service.set_notes(store, report_id, notes)
105
+ view = get_report(store, report_id)
106
+ view["warnings"] = service.report_ref_warnings(store, report.notes)
107
+ return view
@@ -94,7 +94,8 @@
94
94
  var i = document.createElement("img");
95
95
  i.src = m.url; i.alt = m.label || ("image " + m.index); fig.appendChild(i);
96
96
  }
97
- var label = caption || ("#" + m.index);
97
+ var letters = {image: "i", video: "v", animated: "g", frames: "f"};
98
+ var label = caption || ("@" + (letters[m.kind] || "i") + m.index);
98
99
  if (m.label) label += " (" + m.label + ")";
99
100
  fig.appendChild(el("figcaption", label + " · " + m.kind + " · " + Math.round(m.size_bytes / 1024) + " KB"));
100
101
  return fig;
@@ -22,6 +22,10 @@ TK_HINT = (
22
22
  )
23
23
  NO_DESKTOP = "Live mode needs a desktop session (no DISPLAY or WAYLAND_DISPLAY is set)."
24
24
  THUMB = 420
25
+ # Light palette; the control and details windows share it (see `_App.style`).
26
+ BG, CARD, TEXT, MUTED = "#f4f5f7", "#ffffff", "#111827", "#6b7280"
27
+ ACCENT, ACCENT_ACTIVE, BORDER = "#2563eb", "#1d4ed8", "#d1d5db"
28
+ DANGER, WARNING = "#b91c1c", "#b45309"
25
29
 
26
30
 
27
31
  def check_environment() -> tuple[int, str]:
@@ -69,25 +73,67 @@ class _App:
69
73
  self.widgets: dict = {}
70
74
  self.pending_drafts: list = []
71
75
 
76
+ # --- styling ---------------------------------------------------------------
77
+
78
+ def style(self) -> None:
79
+ """One consistent look for every window: a flat light theme with an accent button."""
80
+ tk, ttk = self.tk, self.ttk
81
+ import tkinter.font as tkfont
82
+
83
+ base = tkfont.nametofont("TkDefaultFont")
84
+ self.body_font = base
85
+ self.title_font = base.copy()
86
+ self.title_font.configure(size=base.cget("size") + 4, weight="bold")
87
+ self.heading_font = base.copy()
88
+ self.heading_font.configure(weight="bold")
89
+ self.root.configure(background=BG)
90
+ style = ttk.Style(self.root)
91
+ if "clam" in style.theme_names():
92
+ style.theme_use("clam")
93
+ style.configure(".", background=BG, foreground=TEXT, font=base)
94
+ style.configure("TFrame", background=BG)
95
+ style.configure("Card.TFrame", background=CARD, bordercolor=BORDER, relief="solid", borderwidth=1)
96
+ style.configure("TLabel", background=BG, foreground=TEXT)
97
+ style.configure("Card.TLabel", background=CARD)
98
+ style.configure("Title.TLabel", font=self.title_font)
99
+ style.configure("Heading.TLabel", font=self.heading_font)
100
+ style.configure("Muted.TLabel", foreground=MUTED)
101
+ style.configure("Muted.Card.TLabel", background=CARD, foreground=MUTED)
102
+ style.configure("Error.TLabel", foreground=DANGER)
103
+ style.configure("Warning.TLabel", foreground=WARNING)
104
+ style.configure("TButton", padding=(14, 6), background=CARD, bordercolor=BORDER, lightcolor=CARD,
105
+ darkcolor=CARD, focuscolor=CARD)
106
+ style.map("TButton", background=[("active", BG), ("disabled", BG)], foreground=[("disabled", MUTED)])
107
+ style.configure("Accent.TButton", padding=(18, 8), background=ACCENT, foreground="#ffffff",
108
+ bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT, focuscolor=ACCENT)
109
+ style.map("Accent.TButton", background=[("active", ACCENT_ACTIVE), ("disabled", BORDER)],
110
+ foreground=[("disabled", MUTED)])
111
+ style.configure("TEntry", fieldbackground=CARD, bordercolor=BORDER, lightcolor=BORDER,
112
+ darkcolor=BORDER, padding=4)
113
+ style.map("TEntry", bordercolor=[("focus", ACCENT)], lightcolor=[("focus", ACCENT)])
114
+ style.configure("TCombobox", fieldbackground=CARD, bordercolor=BORDER, padding=3)
115
+
72
116
  # --- control window --------------------------------------------------------
73
117
 
74
118
  def start_ui(self) -> None:
75
119
  tk, ttk, root = self.tk, self.ttk, self.root
76
120
  root.title("bugcap live")
77
121
  root.resizable(False, False)
122
+ self.style()
78
123
  notice = ""
79
124
  try:
80
125
  root.attributes("-topmost", True)
81
126
  except tk.TclError:
82
127
  notice = "Always-on-top is not supported here; the window may be hidden by others."
83
- frame = ttk.Frame(root, padding=12)
128
+ frame = ttk.Frame(root, padding=16)
84
129
  frame.grid()
85
- self.start_button = ttk.Button(frame, text="Start", command=self.on_start)
86
- self.start_button.grid(row=0, column=0, sticky="ew")
87
- self.counter = ttk.Label(frame, text="0 saved this session")
88
- self.counter.grid(row=1, column=0, pady=(8, 0))
89
- self.status = ttk.Label(frame, text=notice, wraplength=240, foreground="#a15c00")
90
- self.status.grid(row=2, column=0, pady=(4, 0))
130
+ ttk.Label(frame, text="bugcap live", style="Title.TLabel").grid(row=0, column=0, sticky="w")
131
+ self.start_button = ttk.Button(frame, text="Start capture", style="Accent.TButton", command=self.on_start)
132
+ self.start_button.grid(row=1, column=0, sticky="ew", pady=(12, 0))
133
+ self.counter = ttk.Label(frame, text="0 saved this session", style="Muted.TLabel")
134
+ self.counter.grid(row=2, column=0, pady=(10, 0))
135
+ self.status = ttk.Label(frame, text=notice, wraplength=260, style="Warning.TLabel")
136
+ self.status.grid(row=3, column=0, pady=(6, 0), sticky="w")
91
137
  root.protocol("WM_DELETE_WINDOW", self.on_close_control)
92
138
  self.pending_drafts = drafts.list_drafts(self.cfg.key if self.cfg else None)
93
139
  root.after(200, self.offer_drafts)
@@ -166,44 +212,56 @@ class _App:
166
212
  win.attributes("-topmost", True)
167
213
  except tk.TclError:
168
214
  pass
169
- frame = ttk.Frame(win, padding=12)
215
+ self.style()
216
+ frame = ttk.Frame(win, padding=16)
170
217
  frame.grid()
171
218
  w = self.widgets = {}
219
+ ttk.Label(frame, text="New bug report", style="Title.TLabel").grid(row=0, column=0, columnspan=2, sticky="w")
172
220
 
221
+ preview = ttk.Frame(frame, style="Card.TFrame", padding=6)
222
+ preview.grid(row=1, column=0, columnspan=2, pady=(10, 12))
173
223
  try:
174
224
  image = tk.PhotoImage(file=str(self.session.screenshot))
175
225
  factor = max(1, -(-max(image.width(), image.height()) // THUMB))
176
226
  w["photo"] = image.subsample(factor, factor) if factor > 1 else image
177
- ttk.Label(frame, image=w["photo"]).grid(row=0, column=0, columnspan=2, pady=(0, 8))
227
+ ttk.Label(preview, image=w["photo"], style="Card.TLabel").grid()
178
228
  except tk.TclError:
179
- ttk.Label(frame, text="(preview unavailable)").grid(row=0, column=0, columnspan=2)
229
+ ttk.Label(preview, text="(preview unavailable)", style="Muted.Card.TLabel").grid()
180
230
 
181
231
  prefill = prefill or {}
182
- ttk.Label(frame, text="Title").grid(row=1, column=0, sticky="w")
183
- w["title"] = ttk.Entry(frame, width=48)
184
- w["title"].grid(row=1, column=1, sticky="ew")
232
+ form = ttk.Frame(frame)
233
+ form.grid(row=2, column=0, columnspan=2, sticky="ew")
234
+ form.columnconfigure(1, weight=1)
235
+ ttk.Label(form, text="Title", style="Heading.TLabel").grid(row=0, column=0, sticky="nw", padx=(0, 12), pady=4)
236
+ w["title"] = ttk.Entry(form, width=48)
237
+ w["title"].grid(row=0, column=1, sticky="ew", pady=4)
185
238
  w["title"].insert(0, prefill.get("title", ""))
186
- ttk.Label(frame, text="Notes").grid(row=2, column=0, sticky="nw")
187
- w["notes"] = tk.Text(frame, width=48, height=6, wrap="word")
188
- w["notes"].grid(row=2, column=1, sticky="ew")
239
+ ttk.Label(form, text="Notes", style="Heading.TLabel").grid(row=1, column=0, sticky="nw", padx=(0, 12), pady=4)
240
+ w["notes"] = tk.Text(
241
+ form, width=48, height=6, wrap="word", relief="flat", background=CARD, foreground=TEXT,
242
+ highlightthickness=1, highlightbackground=BORDER, highlightcolor=ACCENT, padx=6, pady=4,
243
+ font=self.body_font,
244
+ )
245
+ w["notes"].grid(row=1, column=1, sticky="ew", pady=4)
189
246
  w["notes"].insert("1.0", prefill.get("notes", ""))
190
247
  w["notes"].bind("<KeyRelease>", lambda e: self.check_notes())
191
- ttk.Label(frame, text="Tags").grid(row=3, column=0, sticky="w")
192
- w["tags"] = ttk.Entry(frame, width=48)
193
- w["tags"].grid(row=3, column=1, sticky="ew")
248
+ ttk.Label(form, text="Tags", style="Heading.TLabel").grid(row=2, column=0, sticky="w", padx=(0, 12), pady=4)
249
+ w["tags"] = ttk.Entry(form, width=48)
250
+ w["tags"].grid(row=2, column=1, sticky="ew", pady=4)
194
251
  w["tags"].insert(0, ", ".join(initial_tags(self.cfg, prefill)))
195
- ttk.Label(frame, text="Status").grid(row=4, column=0, sticky="w")
196
- w["status"] = ttk.Combobox(frame, values=list(STATUSES), state="readonly", width=16)
252
+ ttk.Label(form, text="Status", style="Heading.TLabel").grid(row=3, column=0, sticky="w", padx=(0, 12), pady=4)
253
+ w["status"] = ttk.Combobox(form, values=list(STATUSES), state="readonly", width=16)
197
254
  w["status"].set(prefill.get("status", "open"))
198
- w["status"].grid(row=4, column=1, sticky="w")
199
- ttk.Label(frame, text="Repo").grid(row=5, column=0, sticky="w")
200
- ttk.Label(frame, text=(self.cfg.key if self.cfg else "(none)")).grid(row=5, column=1, sticky="w")
201
- w["error"] = ttk.Label(frame, text="", foreground="#b00020", wraplength=420)
202
- w["error"].grid(row=6, column=0, columnspan=2, sticky="w", pady=(6, 0))
255
+ w["status"].grid(row=3, column=1, sticky="w", pady=4)
256
+ ttk.Label(form, text="Repo", style="Heading.TLabel").grid(row=4, column=0, sticky="w", padx=(0, 12), pady=4)
257
+ ttk.Label(form, text=(self.cfg.key if self.cfg else "(none)"), style="Muted.TLabel").grid(
258
+ row=4, column=1, sticky="w", pady=4)
259
+ w["error"] = ttk.Label(frame, text="", style="Error.TLabel", wraplength=420)
260
+ w["error"].grid(row=3, column=0, columnspan=2, sticky="w", pady=(6, 0))
203
261
  buttons = ttk.Frame(frame)
204
- buttons.grid(row=7, column=0, columnspan=2, sticky="e", pady=(8, 0))
205
- ttk.Button(buttons, text="Discard", command=self.on_discard).grid(row=0, column=0, padx=4)
206
- ttk.Button(buttons, text="Save", command=self.on_save).grid(row=0, column=1)
262
+ buttons.grid(row=4, column=0, columnspan=2, sticky="e", pady=(12, 0))
263
+ ttk.Button(buttons, text="Discard", command=self.on_discard).grid(row=0, column=0, padx=(0, 8))
264
+ ttk.Button(buttons, text="Save", style="Accent.TButton", command=self.on_save).grid(row=0, column=1)
207
265
  win.protocol("WM_DELETE_WINDOW", self.on_close_details)
208
266
  w["title"].focus_set()
209
267
  self.check_notes()
@@ -182,13 +182,13 @@ def build_server():
182
182
  @tool()
183
183
  def attach_image(id: int, sources: list[str], labels: Optional[list[Optional[str]]] = None) -> str:
184
184
  """Attach images (file paths, globs or http(s) URLs) to a report, with optional labels
185
- (one per source, null for none). Notes can then refer to them as @1 or @label."""
185
+ (one per source, null for none). Notes can then refer to them as @i1 or @label."""
186
186
  with Store() as store:
187
187
  return json.dumps(agent_api.attach_image(store, id, sources, labels))
188
188
 
189
189
  @tool()
190
190
  def update_notes(id: int, notes: str) -> str:
191
- """Replace a report's notes. @1 / @label references must point at the report's images."""
191
+ """Replace a report's notes. @i1 / @label references must point at the report's images."""
192
192
  with Store() as store:
193
193
  return json.dumps(agent_api.update_notes(store, id, notes))
194
194
 
@@ -1,4 +1,10 @@
1
- """`@` references in notes: `@3` (media index), `@login-error` (media label), `@@` (literal @).
1
+ """References in notes.
2
+
3
+ `@` points at media, by kind letter and its number in the report: `@i3` image, `@v2` video,
4
+ `@g4` animated GIF, `@f5` keyframes. `@login-error` is a media label and `@@` a literal `@`.
5
+ The older `@3` form still reads as media 3, so existing notes keep working.
6
+
7
+ `#N` points at report N (soft: an unknown number is kept as text and reported as a warning).
2
8
 
3
9
  References are ignored inside fenced code blocks, inline code spans and email-like text.
4
10
  Everything here is pure text processing; the rules for what a reference may point at
@@ -13,13 +19,19 @@ from .errors import ServiceError
13
19
 
14
20
  REMOVED_TEXT = "[image removed]"
15
21
  LABEL_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_\-]*$")
22
+ MEDIA_LETTERS = {"image": "i", "video": "v", "animated": "g", "frames": "f"}
23
+ # `v2`, `G4`, ...: a kind letter and a number. Labels may not look like this (see service.validate_label).
24
+ INDEX_RE = re.compile(r"^([ivgfIVGF])([1-9][0-9]*)$")
25
+ RESERVED_LABEL_RE = re.compile(r"^[ivgfIVGF][0-9]+$") # any kind letter + digits, even i0
26
+ _LEGACY_INDEX_RE = re.compile(r"^([1-9][0-9]*)$")
27
+ _REPORT_RE = re.compile(r"(?:^|(?<=[\s(\[]))#([1-9][0-9]*)(?![\w\-])", re.M)
16
28
  _REF_RE = re.compile(r"@@|(?<![\w.%+\-])@([A-Za-z0-9_\-]+)(?![\w\-])")
17
29
  _FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
18
30
 
19
31
 
20
32
  @dataclass
21
33
  class Ref:
22
- token: str # text as written, e.g. "@1", "@login-error", "@@"
34
+ token: str # text as written, e.g. "@i1", "@login-error", "@@"
23
35
  start: int
24
36
  end: int
25
37
  kind: Literal["index", "label", "escape", "unknown"]
@@ -28,6 +40,14 @@ class Ref:
28
40
  def name(self) -> str:
29
41
  return self.token[1:]
30
42
 
43
+ @property
44
+ def key(self) -> str:
45
+ """What the reference points at: the image number for an index (`@i1` and `@1` both
46
+ give "1"), otherwise the name as written. Used to match rewrite mappings."""
47
+ if self.kind == "index":
48
+ return index_number(self.name) or self.name
49
+ return self.name
50
+
31
51
 
32
52
  def _blank(text: str) -> str:
33
53
  return "".join(ch if ch == "\n" else " " for ch in text)
@@ -103,8 +123,43 @@ def _mask_spans(text: str) -> str:
103
123
  return "".join(chars)
104
124
 
105
125
 
126
+ def index_number(name: str) -> Optional[str]:
127
+ """`v3`, `I3` or the older `3` -> "3"; anything else -> None."""
128
+ match = INDEX_RE.match(name)
129
+ if match:
130
+ return match.group(2)
131
+ match = _LEGACY_INDEX_RE.match(name)
132
+ return match.group(1) if match else None
133
+
134
+
135
+ def index_letter(name: str) -> Optional[str]:
136
+ """The lower-case kind letter of `v3` ("v"); None for the older `3` form."""
137
+ match = INDEX_RE.match(name)
138
+ return match.group(1).lower() if match else None
139
+
140
+
141
+ def media_token(item) -> str:
142
+ """The reference that points at a media item as written in notes: `@i1`, `@v2`, `@g3`, `@f4`."""
143
+ return f"@{MEDIA_LETTERS.get(item.kind, 'i')}{item.idx}"
144
+
145
+
146
+ def report_refs(notes: str) -> list[int]:
147
+ """Report numbers referenced by `#N` in the notes (code and emails excluded)."""
148
+ return [int(m.group(1)) for m in _REPORT_RE.finditer(mask(notes or ""))]
149
+
150
+
151
+ def substitute_reports(notes: str, mapping: dict) -> str:
152
+ """Replace `#N` by `mapping[N]` where present (other `#N` are left as written)."""
153
+ out = notes or ""
154
+ for m in reversed(list(_REPORT_RE.finditer(mask(out)))):
155
+ number = int(m.group(1))
156
+ if number in mapping:
157
+ out = out[: m.start()] + mapping[number] + out[m.end():]
158
+ return out
159
+
160
+
106
161
  def _classify(name: str) -> str:
107
- if name.isdigit() and not name.startswith("0"):
162
+ if index_number(name) is not None:
108
163
  return "index"
109
164
  if LABEL_RE.match(name):
110
165
  return "label"
@@ -123,10 +178,12 @@ def parse_references(notes: str) -> list[Ref]:
123
178
 
124
179
 
125
180
  def resolve(ref: Ref, media: list):
126
- """The media item a reference points at (label match is case-sensitive), or None."""
181
+ """The media item a reference points at (label match is case-sensitive), or None.
182
+ `@v2` only matches a video; the older `@2` matches whatever item is number 2."""
127
183
  if ref.kind == "index":
184
+ letter = index_letter(ref.name)
128
185
  for item in media:
129
- if item.idx == int(ref.name):
186
+ if item.idx == int(ref.key) and (letter is None or MEDIA_LETTERS.get(item.kind) == letter):
130
187
  return item
131
188
  elif ref.kind == "label":
132
189
  for item in media:
@@ -136,7 +193,7 @@ def resolve(ref: Ref, media: list):
136
193
 
137
194
 
138
195
  def valid_tokens(media: list) -> list[str]:
139
- indexes = [f"@{m.idx}" for m in sorted(media, key=lambda m: m.idx)]
196
+ indexes = [media_token(m) for m in sorted(media, key=lambda m: m.idx)]
140
197
  labels = [f"@{m.label}" for m in sorted(media, key=lambda m: m.idx) if m.label]
141
198
  return indexes + labels
142
199
 
@@ -158,8 +215,8 @@ def _rewrite(notes: str, mapping: dict[str, str]) -> tuple[str, int]:
158
215
  out = notes
159
216
  count = 0
160
217
  for ref in reversed(parse_references(notes)):
161
- if ref.kind in ("index", "label") and ref.name in mapping:
162
- replacement = mapping[ref.name]
218
+ if ref.kind in ("index", "label") and ref.key in mapping:
219
+ replacement = mapping[ref.key]
163
220
  if replacement != ref.token:
164
221
  out = out[: ref.start] + replacement + out[ref.end :]
165
222
  count += 1
@@ -167,7 +224,7 @@ def _rewrite(notes: str, mapping: dict[str, str]) -> tuple[str, int]:
167
224
 
168
225
 
169
226
  def rewrite_references(notes: str, mapping: dict[str, str]) -> str:
170
- """Replace references by name (`{"2": "@1", "old": "@new", "3": "[image removed]"}`).
227
+ """Replace references by key (`{"2": "@i1", "old": "@new", "3": "[image removed]"}`).
171
228
  Code, emails and `@@` are never touched; other text is preserved exactly."""
172
229
  return _rewrite(notes, mapping)[0]
173
230
 
@@ -186,7 +243,7 @@ def references_to(notes: str, item, media_all: list) -> list[Ref]:
186
243
 
187
244
 
188
245
  def display_notes(notes: str, media: list) -> str:
189
- """Notes for terminal display: `@1` becomes `@1 (images/…)` and `@@` prints as `@`."""
246
+ """Notes for terminal display: `@i1` becomes `@i1 (images/…)` and `@@` prints as `@`."""
190
247
  out = notes or ""
191
248
  for ref in reversed(parse_references(out)):
192
249
  if ref.kind == "escape":