agentstreamdeck 2.1.1__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.
Files changed (65) hide show
  1. agentstreamdeck-2.1.1.dist-info/METADATA +1013 -0
  2. agentstreamdeck-2.1.1.dist-info/RECORD +65 -0
  3. agentstreamdeck-2.1.1.dist-info/WHEEL +5 -0
  4. agentstreamdeck-2.1.1.dist-info/entry_points.txt +2 -0
  5. agentstreamdeck-2.1.1.dist-info/licenses/LICENSE +201 -0
  6. agentstreamdeck-2.1.1.dist-info/top_level.txt +1 -0
  7. ocdeck/__init__.py +1 -0
  8. ocdeck/__main__.py +307 -0
  9. ocdeck/alerts.py +120 -0
  10. ocdeck/appearance.py +133 -0
  11. ocdeck/appearance_io.py +70 -0
  12. ocdeck/art.py +181 -0
  13. ocdeck/assets/logos/OCTICONS-LICENSE.txt +21 -0
  14. ocdeck/assets/logos/claude.png +0 -0
  15. ocdeck/assets/logos/copilot.png +0 -0
  16. ocdeck/assets/logos/copilot.svg +1 -0
  17. ocdeck/assets/logos/cursor.png +0 -0
  18. ocdeck/assets/logos/gemini.png +0 -0
  19. ocdeck/assets/logos/opencode.png +0 -0
  20. ocdeck/assets/logos/sources.json +27 -0
  21. ocdeck/broker.py +273 -0
  22. ocdeck/common.py +82 -0
  23. ocdeck/device.py +247 -0
  24. ocdeck/diagnostics.py +227 -0
  25. ocdeck/errors.py +22 -0
  26. ocdeck/focus.py +156 -0
  27. ocdeck/hardware_check.py +66 -0
  28. ocdeck/harness.py +203 -0
  29. ocdeck/launcher.py +203 -0
  30. ocdeck/model.py +177 -0
  31. ocdeck/observability.py +57 -0
  32. ocdeck/runtime/plugins/core.mjs +122 -0
  33. ocdeck/runtime/plugins/harnesses/bridge.mjs +52 -0
  34. ocdeck/runtime/plugins/harnesses/hook.mjs +38 -0
  35. ocdeck/runtime/plugins/harnesses/install.mjs +83 -0
  36. ocdeck/runtime/plugins/harnesses/profiles.mjs +107 -0
  37. ocdeck/runtime/plugins/server.mjs +67 -0
  38. ocdeck/runtime/plugins/tui.mjs +44 -0
  39. ocdeck/runtime/scripts/Install-Harness.ps1 +20 -0
  40. ocdeck/runtime/scripts/Install.ps1 +79 -0
  41. ocdeck/runtime/scripts/Launch-Agent.bat +7 -0
  42. ocdeck/runtime/scripts/Launch-Claude.bat +7 -0
  43. ocdeck/runtime/scripts/Launch-Codex.bat +7 -0
  44. ocdeck/runtime/scripts/Launch-Copilot-VSCode.bat +7 -0
  45. ocdeck/runtime/scripts/Launch-Copilot.bat +7 -0
  46. ocdeck/runtime/scripts/Launch-Cursor.bat +7 -0
  47. ocdeck/runtime/scripts/Launch-Gemini.bat +7 -0
  48. ocdeck/runtime/scripts/Remove-Integration.ps1 +35 -0
  49. ocdeck/runtime/scripts/Run-OpenCode.ps1 +7 -0
  50. ocdeck/runtime/scripts/Test.ps1 +11 -0
  51. ocdeck/runtime/scripts/Uninstall.ps1 +11 -0
  52. ocdeck/runtime/scripts/Verify-Windows.ps1 +13 -0
  53. ocdeck/runtime/scripts/check-js.py +8 -0
  54. ocdeck/runtime/scripts/examples/Claude-Cloud.bat +6 -0
  55. ocdeck/runtime/scripts/examples/Claude-Local.bat +21 -0
  56. ocdeck/runtime/scripts/examples/HomeAILab-Claude-5090.bat +8 -0
  57. ocdeck/runtime/scripts/examples/HomeAILab-Claude-Cluster.bat +8 -0
  58. ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-5090.bat +8 -0
  59. ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-Spark.bat +8 -0
  60. ocdeck/runtime/scripts/examples/OpenCode-Cloud.bat +8 -0
  61. ocdeck/runtime/scripts/render-gallery.py +94 -0
  62. ocdeck/security.py +38 -0
  63. ocdeck/settings.py +42 -0
  64. ocdeck/uninstall.py +100 -0
  65. ocdeck/updates.py +46 -0
ocdeck/alerts.py ADDED
@@ -0,0 +1,120 @@
1
+ """State transitions are processed independently of USB rendering."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import queue
7
+ import subprocess
8
+ import threading
9
+ import time
10
+ from pathlib import Path
11
+
12
+ LOG = logging.getLogger(__name__)
13
+
14
+
15
+ def quiet_hours():
16
+ """Conservative Windows shell interruption check; failure suppresses sound."""
17
+ if os.name != "nt":
18
+ return False
19
+ try:
20
+ import ctypes
21
+
22
+ state = ctypes.c_int()
23
+ result = ctypes.windll.shell32.SHQueryUserNotificationState(ctypes.byref(state))
24
+ return result != 0 or state.value != 5 # QUNS_ACCEPTS_NOTIFICATIONS
25
+ except Exception:
26
+ return True
27
+
28
+
29
+ def deliver(event, config):
30
+ if os.name != "nt":
31
+ LOG.info("Alerts require Windows: slot %s state %s", event["slot"] + 1, event["state"])
32
+ return
33
+ if quiet_hours():
34
+ return
35
+ if event["sound"]:
36
+ import winsound
37
+
38
+ file = config.get("sound_file")
39
+ winsound.PlaySound(
40
+ str(Path(file).expanduser()) if file else "SystemExclamation",
41
+ winsound.SND_ASYNC | (winsound.SND_FILENAME if file else winsound.SND_ALIAS),
42
+ )
43
+ if event["toast"]:
44
+ # Native WinRT notification; default priority, no alarm scenario or bypass.
45
+ # User labels/prompts never enter either the shell script or notification.
46
+ import winreg
47
+
48
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, r"Software\Classes\AppUserModelId\AgentDeck") as key:
49
+ winreg.SetValueEx(key, "DisplayName", 0, winreg.REG_SZ, "AgentStreamDeck")
50
+ text = f"AgentStreamDeck slot {event['slot'] + 1} needs input"
51
+ script = """$ErrorActionPreference='Stop'
52
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null
53
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] > $null
54
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
55
+ $xml.LoadXml('<toast><visual><binding template="ToastGeneric"><text>TEXT</text></binding></visual><audio silent="true"/></toast>')
56
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
57
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('AgentDeck').Show($toast)
58
+ """.replace("TEXT", text)
59
+ subprocess.run(
60
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
61
+ timeout=5,
62
+ check=True,
63
+ capture_output=True,
64
+ creationflags=0x08000000,
65
+ )
66
+
67
+
68
+ class Alerts:
69
+ def __init__(self, config, clock=time.monotonic, sink=deliver):
70
+ self.config = config.get("alerts", {})
71
+ self.clock, self.sink = clock, sink
72
+ self.previous = {}
73
+ self.seen = set()
74
+ self.last_sound = -float("inf")
75
+ self.lock = threading.Lock()
76
+ self.queue = queue.Queue(maxsize=64)
77
+
78
+ def observe(self, views):
79
+ with self.lock:
80
+ live = {v["id"] for v in views if v["id"]}
81
+ self.previous = {k: v for k, v in self.previous.items() if k in live}
82
+ self.seen = {x for x in self.seen if x[0] in live}
83
+ for view in views:
84
+ identity = view["id"]
85
+ if not identity:
86
+ continue
87
+ state = view["state"]
88
+ previous = self.previous.get(identity)
89
+ self.previous[identity] = state
90
+ muted = str(view["slot"] + 1) in self.config.get("muted_slots", [])
91
+ requests = view.get("requestIds", []) if state == "input" else []
92
+ fresh = [r for r in requests if (identity, r) not in self.seen]
93
+ self.seen.update((identity, r) for r in requests)
94
+ transition = state != previous
95
+ sound = (
96
+ not muted
97
+ and self.config.get("sound", False)
98
+ and transition
99
+ and state in self.config.get("states", ["input"])
100
+ and self.clock() - self.last_sound >= self.config.get("cooldown_seconds", 10)
101
+ )
102
+ toast = not muted and self.config.get("toast", False) and bool(fresh)
103
+ if sound or toast:
104
+ if sound:
105
+ self.last_sound = self.clock()
106
+ try:
107
+ self.queue.put_nowait({"slot": view["slot"], "state": state, "sound": sound, "toast": toast})
108
+ except queue.Full:
109
+ LOG.warning("Alert queue full; alert dropped")
110
+
111
+ def run(self, stop):
112
+ while not stop.is_set():
113
+ try:
114
+ event = self.queue.get(timeout=0.2)
115
+ except queue.Empty:
116
+ continue
117
+ try:
118
+ self.sink(event, self.config)
119
+ except Exception:
120
+ LOG.exception("Alert delivery failed; run ocdeck doctor")
ocdeck/appearance.py ADDED
@@ -0,0 +1,133 @@
1
+ """Validated, hashable button preferences shared by the device and preview."""
2
+
3
+ from dataclasses import dataclass, fields
4
+ import math
5
+
6
+ THEMES = {
7
+ "high-contrast": ("00ff88", "ffff00", "ff8090", "ffffff", "ffffff"),
8
+ "classic": ("20eb75", "ffb32e", "ff374b", "32ccff", "ffb32e"),
9
+ "aurora": ("59f3c2", "d5a6ff", "ff668f", "78bcff", "e5c180"),
10
+ "ocean": ("42e8c4", "ffd280", "ff7088", "69bfff", "c4a7ff"),
11
+ "accessible": ("56b4e9", "e69f00", "cc79a7", "f0e442", "ffffff"),
12
+ "mono": ("ffffff", "bbbbbb", "ffffff", "dddddd", "999999"),
13
+ }
14
+ HARNESS_NAMES = {
15
+ "codex": "Codex",
16
+ "opencode": "OpenCode",
17
+ "claude": "Claude",
18
+ "copilot": "Copilot",
19
+ "copilot-cli": "Copilot",
20
+ "copilot-vscode": "Copilot",
21
+ "gemini": "Gemini",
22
+ "cursor": "Cursor",
23
+ }
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Appearance:
28
+ layout: str = "classic"
29
+ theme: str = "classic"
30
+ primary: str = "status"
31
+ secondary: str = "project"
32
+ custom_text: str = ""
33
+ show_slot: bool = True
34
+ effect: str = "breathe"
35
+ intensity: float = 0.55
36
+ speed: float = 1.0
37
+ brightness: float = 1.0
38
+ alias: str = ""
39
+ text_effect: str = "none"
40
+ text_size: str = "normal"
41
+ text_align: str = "center"
42
+ badge: str = "dot"
43
+ border: str = "solid"
44
+ background: str = "solid"
45
+ logo_size: str = "normal"
46
+
47
+
48
+ def appearance(config, slot=0):
49
+ raw = config.get("appearance", {})
50
+ if not isinstance(raw, dict):
51
+ raise ValueError("appearance must be an object")
52
+ overrides = config.get("buttons", {})
53
+ if not isinstance(overrides, dict):
54
+ raise ValueError("buttons must be an object")
55
+ extra = overrides.get(str(slot + 1), {})
56
+ if not isinstance(extra, dict):
57
+ raise ValueError("button override must be an object")
58
+ values = {**raw, **extra}
59
+ unknown = set(values) - {f.name for f in fields(Appearance)}
60
+ if unknown:
61
+ raise ValueError("Unknown appearance setting: " + ", ".join(sorted(unknown)))
62
+ a = Appearance(**values)
63
+ for name, choices in {
64
+ "layout": ("classic", "harness", "minimal"),
65
+ "theme": THEMES,
66
+ "primary": ("status", "project", "harness", "detail", "custom", "alias", "none"),
67
+ "secondary": ("status", "project", "harness", "detail", "custom", "alias", "none"),
68
+ "text_effect": ("none", "scroll", "shimmer"),
69
+ "text_size": ("small", "normal", "large"),
70
+ "text_align": ("left", "center", "right"),
71
+ "badge": ("dot", "ring", "pill"),
72
+ "border": ("solid", "double", "corners", "none"),
73
+ "background": ("solid", "gradient", "grid"),
74
+ "logo_size": ("small", "normal", "large"),
75
+ "effect": ("breathe", "glow", "steady"),
76
+ }.items():
77
+ if not isinstance(getattr(a, name), str) or getattr(a, name) not in choices:
78
+ raise ValueError(f"Invalid {name}")
79
+ for name, low, high in [("intensity", 0, 1), ("speed", 0.25, 3), ("brightness", 0.15, 1)]:
80
+ value = getattr(a, name)
81
+ if type(value) not in (int, float) or not math.isfinite(value) or not low <= value <= high:
82
+ raise ValueError(f"{name} must be {low}..{high}")
83
+ if type(a.show_slot) is not bool:
84
+ raise ValueError("show_slot must be boolean")
85
+ for name in ("custom_text", "alias"):
86
+ if not isinstance(getattr(a, name), str) or len(getattr(a, name)) > 100:
87
+ raise ValueError(f"{name} must be at most 100 characters")
88
+ return a
89
+
90
+
91
+ def harness_id(label, explicit=""):
92
+ return explicit or (label.split(":", 1)[0] if label.split(":", 1)[0] in HARNESS_NAMES else "opencode")
93
+
94
+
95
+ def animation_phase(now, a, enabled=True):
96
+ return int(now * 48 * a.speed) % 96 if enabled and a.effect != "steady" else 24
97
+
98
+
99
+ PRESETS = {
100
+ "studio": dict(
101
+ layout="harness", theme="aurora", primary="alias", secondary="status", show_slot=False, background="gradient"
102
+ ),
103
+ "neon": dict(
104
+ layout="harness",
105
+ theme="ocean",
106
+ effect="glow",
107
+ intensity=0.85,
108
+ border="double",
109
+ background="grid",
110
+ text_effect="shimmer",
111
+ ),
112
+ "focus": dict(
113
+ layout="harness",
114
+ theme="mono",
115
+ effect="steady",
116
+ primary="alias",
117
+ secondary="status",
118
+ border="corners",
119
+ show_slot=False,
120
+ ),
121
+ "readable": dict(
122
+ layout="minimal", theme="accessible", text_size="large", primary="status", secondary="alias", effect="steady"
123
+ ),
124
+ "marquee": dict(
125
+ layout="harness",
126
+ theme="aurora",
127
+ primary="alias",
128
+ secondary="status",
129
+ text_effect="scroll",
130
+ speed=0.5,
131
+ show_slot=False,
132
+ ),
133
+ }
@@ -0,0 +1,70 @@
1
+ """Versioned appearance files contain only visual settings."""
2
+
3
+ import copy
4
+ from dataclasses import asdict
5
+ from .appearance import appearance
6
+
7
+
8
+ def validate(value):
9
+ if not isinstance(value, dict):
10
+ raise ValueError("Appearance file must be an object")
11
+ unknown = set(value) - {"schema_version", "appearance", "buttons", "fps"}
12
+ if unknown:
13
+ raise ValueError("Unknown settings: " + ", ".join(sorted(unknown)))
14
+ if type(value.get("schema_version")) is not int or value["schema_version"] != 1:
15
+ raise ValueError("schema_version must be 1")
16
+ fps = value.get("fps", 24)
17
+ if type(fps) is not int or not 1 <= fps <= 30:
18
+ raise ValueError("fps must be 1..30")
19
+ buttons = value.get("buttons", {})
20
+ if not isinstance(buttons, dict) or any(k not in {str(i) for i in range(1, 33)} for k in buttons):
21
+ raise ValueError("buttons must use slot keys 1..32")
22
+ for slot in range(32):
23
+ appearance(value, slot)
24
+ return copy.deepcopy(value)
25
+
26
+
27
+ def export_settings(config):
28
+ return validate({"schema_version": 1, **{k: config[k] for k in ("appearance", "buttons", "fps") if k in config}})
29
+
30
+
31
+ def import_settings(config, value):
32
+ value = validate(value)
33
+ result = copy.deepcopy(config)
34
+ for key in ("appearance", "buttons", "fps"):
35
+ result[key] = value.get(key, 24 if key == "fps" else {})
36
+ return result
37
+
38
+
39
+ def diff(before, after):
40
+ return {
41
+ k: {"before": before.get(k), "after": after.get(k)}
42
+ for k in ("appearance", "buttons", "fps")
43
+ if before.get(k) != after.get(k)
44
+ }
45
+
46
+
47
+ def preview(config, count=6):
48
+ """In-memory PNG: dry-run emits a data URI and never creates a file."""
49
+ import base64
50
+ import io
51
+ from PIL import Image
52
+ from .art import frame
53
+
54
+ columns = {6: 3, 15: 5, 32: 8}[count]
55
+ image = Image.new("RGB", (columns * 90, ((count + columns - 1) // columns) * 90), "black")
56
+ for slot in range(count):
57
+ image.paste(
58
+ frame(
59
+ ("running", "idle", "input")[slot % 3],
60
+ "Preview",
61
+ slot,
62
+ 24,
63
+ style=appearance(config, slot),
64
+ pending=3 if slot % 3 == 2 else None,
65
+ ),
66
+ ((slot % columns) * 90, (slot // columns) * 90),
67
+ )
68
+ stream = io.BytesIO()
69
+ image.save(stream, format="PNG")
70
+ return "data:image/png;base64," + base64.b64encode(stream.getvalue()).decode("ascii")
ocdeck/art.py ADDED
@@ -0,0 +1,181 @@
1
+ """Device artwork with bundled, attributed official harness icons."""
2
+
3
+ import math
4
+ from functools import lru_cache
5
+ from PIL import Image, ImageDraw, ImageFont, ImageEnhance, ImageChops
6
+ from .appearance import Appearance, THEMES, HARNESS_NAMES
7
+ from pathlib import Path
8
+
9
+ PALETTE = {
10
+ "running": (32, 235, 117),
11
+ "idle": (255, 179, 46),
12
+ "input": (255, 55, 75),
13
+ "ready": (50, 204, 255),
14
+ "unknown": (255, 179, 46),
15
+ }
16
+ LABELS = {"running": "RUNNING", "idle": "IDLE", "input": "INPUT", "ready": "READY", "unknown": "LINK ?"}
17
+
18
+
19
+ @lru_cache(maxsize=1024)
20
+ def frame(state, label, slot, phase, size=80, style=Appearance(), harness="opencode", detail="", pending=None):
21
+ image = Image.new("RGB", (size, size), "black")
22
+ if state == "off":
23
+ return image
24
+ # Draw at 2x for smooth curves, then downsample. Discrete phases bound cache size; one cycle has 96 phases.
25
+ im = Image.new("RGB", (160, 160), (3, 6, 10))
26
+ d = ImageDraw.Draw(im)
27
+ color = tuple(bytes.fromhex(THEMES[style.theme][list(PALETTE).index(state)]))
28
+ t = phase / 96 * 2 * math.pi
29
+ pulse = (1 + math.sin(t)) / 2
30
+ power = 0.45 + 0.55 * pulse if state == "input" else 0.55 + 0.25 * pulse
31
+ power = 1 - style.intensity * (1 - power)
32
+ c = tuple(int(x * power) for x in color)
33
+ draw_background(d, color, c, style)
34
+ if style.layout == "harness" and state != "ready":
35
+ logo = harness_logo(harness, {"small": 42, "normal": 56, "large": 68}[style.logo_size])
36
+ if logo is not None:
37
+ im.paste(logo, (80 - logo.width // 2, 54 - logo.height // 2), logo)
38
+ else:
39
+ # Unknown integrations get a neutral terminal, never another brand.
40
+ d.rounded_rectangle((52, 32, 108, 76), radius=6, outline=color, width=3)
41
+ d.text((80, 54), "?", font=ImageFont.load_default(size=26), fill=color, anchor="mm")
42
+ draw_badge(d, state, c, style.badge)
43
+ elif style.layout == "minimal" and state != "ready":
44
+ d.ellipse((61, 34, 99, 72), fill=c)
45
+ d.text(
46
+ (80, 53),
47
+ {"running": ">", "idle": "II", "input": "!", "unknown": "?"}.get(state, "+"),
48
+ font=ImageFont.load_default(size=24),
49
+ fill="black",
50
+ anchor="mm",
51
+ )
52
+ elif state == "running":
53
+ d.ellipse((48, 23, 112, 87), outline=tuple(int(x * 0.2) for x in color), width=5)
54
+ d.arc((48, 23, 112, 87), phase * 3.75, phase * 3.75 + 110, fill=color, width=6)
55
+ d.polygon([(74, 42), (74, 69), (94, 55)], fill=color)
56
+ elif state == "input":
57
+ radius = 22 + int(pulse * 8)
58
+ d.ellipse((80 - radius, 54 - radius, 80 + radius, 54 + radius), fill=c)
59
+ d.rounded_rectangle((77, 35, 83, 57), radius=2, fill=(15, 2, 3))
60
+ d.ellipse((77, 63, 83, 69), fill=(15, 2, 3))
61
+ elif state == "ready":
62
+ d.arc((47, 21, 113, 87), phase * 3.75, phase * 3.75 + 270, fill=c, width=3)
63
+ d.line([(64, 53), (76, 65), (98, 41)], fill=color, width=6)
64
+ elif state == "idle":
65
+ d.rounded_rectangle((62, 36, 71, 73), radius=3, fill=c)
66
+ d.rounded_rectangle((89, 36, 98, 73), radius=3, fill=c)
67
+ else:
68
+ d.line([(64, 36), (96, 68)], fill=color, width=5)
69
+ d.line([(96, 36), (64, 68)], fill=color, width=5)
70
+ if state == "input" and pending is not None:
71
+ text = "9+" if pending > 9 else str(pending)
72
+ d.rounded_rectangle((112, 76, 150, 99), radius=5, fill="white")
73
+ d.text((131, 87), text, font=ImageFont.load_default(size=16), fill="black", anchor="mm")
74
+ from .security import scrub_text
75
+
76
+ label, detail = scrub_text(label), scrub_text(detail)
77
+ project = label.split(":", 1)[1] if label.split(":", 1)[0] in HARNESS_NAMES and ":" in label else label
78
+ project = style.alias or project
79
+ values = {
80
+ "status": LABELS[state],
81
+ "project": "DEVICE ONLINE" if state == "ready" else project,
82
+ "alias": "DEVICE ONLINE" if state == "ready" else project,
83
+ "harness": HARNESS_NAMES.get(harness, harness),
84
+ "detail": detail,
85
+ "custom": style.custom_text,
86
+ "none": "",
87
+ }
88
+ for field, y, height, fill in [(style.primary, 101, 17, color), (style.secondary, 130, 13, (210, 219, 228))]:
89
+ text = scrub_text(values[field])
90
+ if style.show_slot and field in ("project", "alias") and state != "ready":
91
+ text = f"{slot + 1} {text}"
92
+ render_text(im, text, y, height, fill, style, phase)
93
+ im = im.resize((size, size), Image.Resampling.LANCZOS)
94
+ gain = style.brightness * (1 - style.intensity * 0.22 * (1 - pulse) if style.effect == "glow" else 1)
95
+ return ImageEnhance.Brightness(im).enhance(gain) if gain != 1 else im
96
+
97
+
98
+ @lru_cache(maxsize=32)
99
+ def harness_logo(harness, size):
100
+ canonical = {"copilot-cli": "copilot", "copilot-vscode": "copilot"}.get(harness, harness)
101
+ if canonical not in ("opencode", "claude", "copilot", "gemini", "cursor"):
102
+ return None
103
+ path = Path(__file__).parent / "assets" / "logos" / (canonical + ".png")
104
+ with Image.open(path) as source:
105
+ image = source.convert("RGBA")
106
+ image.thumbnail((size, size), Image.Resampling.LANCZOS)
107
+ return image
108
+
109
+
110
+ def draw_background(d, color, pulse_color, style):
111
+ base = tuple(int(x * 0.08) for x in pulse_color)
112
+ d.rounded_rectangle((3, 3, 156, 156), radius=22, fill=base)
113
+ if style.background == "gradient":
114
+ for y in range(16, 145):
115
+ gain = 0.03 + 0.13 * (1 - abs(y - 80) / 65)
116
+ d.line((14, y, 145, y), fill=tuple(int(x * gain) for x in color))
117
+ elif style.background == "grid":
118
+ grid = tuple(int(x * 0.14) for x in color)
119
+ for pos in range(20, 145, 16):
120
+ d.line((pos, 12, pos, 147), fill=grid)
121
+ d.line((12, pos, 147, pos), fill=grid)
122
+ if style.border in ("solid", "double"):
123
+ d.rounded_rectangle((3, 3, 156, 156), radius=22, outline=pulse_color, width=3)
124
+ if style.border == "double":
125
+ d.rounded_rectangle((9, 9, 150, 150), radius=17, outline=tuple(int(x * 0.45) for x in color), width=1)
126
+ elif style.border == "corners":
127
+ for x, y, dx, dy in [(8, 8, 1, 1), (151, 8, -1, 1), (8, 151, 1, -1), (151, 151, -1, -1)]:
128
+ d.line((x + dx * 24, y, x, y, x, y + dy * 24), fill=pulse_color, width=3)
129
+
130
+
131
+ def draw_badge(d, state, color, kind):
132
+ symbol = {"running": ">", "idle": "II", "input": "!", "unknown": "?"}[state]
133
+ if kind == "pill":
134
+ d.rounded_rectangle((111, 10, 150, 30), radius=7, fill=color)
135
+ label = {"running": "RUN", "idle": "IDLE", "input": "ASK", "unknown": "?"}[state]
136
+ d.text((131, 20), label, font=ImageFont.load_default(size=10), fill="black", anchor="mm")
137
+ elif kind == "ring":
138
+ d.ellipse((125, 10, 149, 34), outline=color, width=3)
139
+ d.text((137, 22), symbol, font=ImageFont.load_default(size=11), fill="white", anchor="mm")
140
+ else:
141
+ d.ellipse((128, 13, 147, 32), fill=color, outline="white", width=2)
142
+
143
+
144
+ def render_text(image, text, y, height, fill, style, phase):
145
+ if not text:
146
+ return
147
+ text = text.encode("ascii", "replace").decode()
148
+ height += {"small": -2, "normal": 0, "large": 3}[style.text_size]
149
+ font = ImageFont.load_default(size=height)
150
+ layer = Image.new("RGBA", (140, 28))
151
+ d = ImageDraw.Draw(layer)
152
+ width = d.textlength(text, font=font)
153
+ if style.text_effect == "scroll" and width > 140:
154
+ # Ping-pong scroll with an endpoint pause, clipped to its own text line.
155
+ t = (phase % 96) / 96
156
+ progress = max(0, min(1, (t - 0.1) / 0.3)) if t < 0.5 else 1 - max(0, min(1, (t - 0.6) / 0.3))
157
+ progress = (1 - math.cos(progress * math.pi)) / 2
158
+ x = -(width - 140) * progress
159
+ else:
160
+ if width > 140:
161
+ lo, hi = 0, len(text)
162
+ while lo < hi:
163
+ mid = (lo + hi + 1) // 2
164
+ if d.textlength(text[:mid] + "...", font=font) <= 140:
165
+ lo = mid
166
+ else:
167
+ hi = mid - 1
168
+ text = text[:lo] + "..."
169
+ width = d.textlength(text, font=font)
170
+ x = {"left": 0, "center": (140 - width) / 2, "right": 140 - width}[style.text_align]
171
+ d.text((x, 14), text, font=font, fill=fill, anchor="lm")
172
+ if style.text_effect == "shimmer":
173
+ shine = Image.new("RGBA", layer.size, "white")
174
+ mask = Image.new("L", layer.size)
175
+ m = ImageDraw.Draw(mask)
176
+ center = -30 + (phase % 96) / 96 * 200
177
+ for col in range(140):
178
+ m.line((col, 0, col, 27), fill=int(180 * max(0, 1 - abs(col - center) / 22)))
179
+ shine.putalpha(ImageChops.multiply(mask, layer.getchannel("A")))
180
+ layer = Image.alpha_composite(layer, shine)
181
+ image.paste(layer, (10, y - 14), layer)
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GitHub Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
Binary file
Binary file
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M23.922 16.992c-.861 1.495-5.859 5.023-11.922 5.023-6.063 0-11.061-3.528-11.922-5.023A.641.641 0 0 1 0 16.736v-2.869a.841.841 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.195 10.195 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952 1.399-1.136 3.392-2.093 6.122-2.093 2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.832.832 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256ZM12.172 11h-.344a4.323 4.323 0 0 1-.355.508C10.703 12.455 9.555 13 7.965 13c-1.725 0-2.989-.359-3.782-1.259a2.005 2.005 0 0 1-.085-.104L4 11.741v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.323 4.323 0 0 1-.355-.508h-.016.016Zm.641-2.935c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/><path d="M14.5 14.25a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Zm-5 0a1 1 0 0 1 1 1v2a1 1 0 0 1-2 0v-2a1 1 0 0 1 1-1Z"/></svg>
Binary file
Binary file
Binary file
@@ -0,0 +1,27 @@
1
+ {
2
+ "opencode": {
3
+ "url": "https://opencode.ai/favicon-96x96-v3.png",
4
+ "source_sha256": "aa34092540de60c889610edfa3c25316e215f12d88af29cfba530d09aee7265c",
5
+ "png_sha256": "8a1c127d6adb1b495660ff81565a2cd0da7203f7235f385464bfa874c2241054"
6
+ },
7
+ "claude": {
8
+ "url": "https://cdn.prod.website-files.com/6889473510b50328dbb70ae6/68c33859cc6cd903686c66a2_apple-touch-icon.png",
9
+ "source_sha256": "1bec5f7b12a4a46fea879633464ebf1d32144ef731a0f054539b2d7251871cb6",
10
+ "png_sha256": "1d943f37f586eec8a8346fe917be598be47a03a4ae746ae3b8f6d8ce273bfc42"
11
+ },
12
+ "cursor": {
13
+ "url": "https://cursor.com/marketing-static/icon-192x192-light.png",
14
+ "source_sha256": "267649be4d2a26dea632bb6cc1f7a400daa117e5cd3335a140ba5cb0148aaedc",
15
+ "png_sha256": "3911203e4da4161447c419c975509c9b2aeb6c930b4fbd2ab532b6c4c26538cf"
16
+ },
17
+ "gemini": {
18
+ "url": "https://www.gstatic.com/lamda/images/gemini_sparkle_4g_512_lt_f94943af3be039176192d.png",
19
+ "source_sha256": "5e7cfecaa53f4f65a313fe89b0f389548126544a78fad8489510c70ae641a4a1",
20
+ "png_sha256": "a77c8a62f06cd08f1da2bf49bd2412433409e1826ab2b6213414080169a332ad"
21
+ },
22
+ "copilot": {
23
+ "url": "https://raw.githubusercontent.com/primer/octicons/main/icons/copilot-24.svg",
24
+ "source_sha256": "eeafb3c2f333e04ccf7d031ae215f7adafaed4c6352556b0bf79496e048bcdd7",
25
+ "png_sha256": "d3272efdb1524b1f650ac6ddd5def2e82158f74797a354da922fee9baa9f7da5"
26
+ }
27
+ }