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.
- agentstreamdeck-2.1.1.dist-info/METADATA +1013 -0
- agentstreamdeck-2.1.1.dist-info/RECORD +65 -0
- agentstreamdeck-2.1.1.dist-info/WHEEL +5 -0
- agentstreamdeck-2.1.1.dist-info/entry_points.txt +2 -0
- agentstreamdeck-2.1.1.dist-info/licenses/LICENSE +201 -0
- agentstreamdeck-2.1.1.dist-info/top_level.txt +1 -0
- ocdeck/__init__.py +1 -0
- ocdeck/__main__.py +307 -0
- ocdeck/alerts.py +120 -0
- ocdeck/appearance.py +133 -0
- ocdeck/appearance_io.py +70 -0
- ocdeck/art.py +181 -0
- ocdeck/assets/logos/OCTICONS-LICENSE.txt +21 -0
- ocdeck/assets/logos/claude.png +0 -0
- ocdeck/assets/logos/copilot.png +0 -0
- ocdeck/assets/logos/copilot.svg +1 -0
- ocdeck/assets/logos/cursor.png +0 -0
- ocdeck/assets/logos/gemini.png +0 -0
- ocdeck/assets/logos/opencode.png +0 -0
- ocdeck/assets/logos/sources.json +27 -0
- ocdeck/broker.py +273 -0
- ocdeck/common.py +82 -0
- ocdeck/device.py +247 -0
- ocdeck/diagnostics.py +227 -0
- ocdeck/errors.py +22 -0
- ocdeck/focus.py +156 -0
- ocdeck/hardware_check.py +66 -0
- ocdeck/harness.py +203 -0
- ocdeck/launcher.py +203 -0
- ocdeck/model.py +177 -0
- ocdeck/observability.py +57 -0
- ocdeck/runtime/plugins/core.mjs +122 -0
- ocdeck/runtime/plugins/harnesses/bridge.mjs +52 -0
- ocdeck/runtime/plugins/harnesses/hook.mjs +38 -0
- ocdeck/runtime/plugins/harnesses/install.mjs +83 -0
- ocdeck/runtime/plugins/harnesses/profiles.mjs +107 -0
- ocdeck/runtime/plugins/server.mjs +67 -0
- ocdeck/runtime/plugins/tui.mjs +44 -0
- ocdeck/runtime/scripts/Install-Harness.ps1 +20 -0
- ocdeck/runtime/scripts/Install.ps1 +79 -0
- ocdeck/runtime/scripts/Launch-Agent.bat +7 -0
- ocdeck/runtime/scripts/Launch-Claude.bat +7 -0
- ocdeck/runtime/scripts/Launch-Codex.bat +7 -0
- ocdeck/runtime/scripts/Launch-Copilot-VSCode.bat +7 -0
- ocdeck/runtime/scripts/Launch-Copilot.bat +7 -0
- ocdeck/runtime/scripts/Launch-Cursor.bat +7 -0
- ocdeck/runtime/scripts/Launch-Gemini.bat +7 -0
- ocdeck/runtime/scripts/Remove-Integration.ps1 +35 -0
- ocdeck/runtime/scripts/Run-OpenCode.ps1 +7 -0
- ocdeck/runtime/scripts/Test.ps1 +11 -0
- ocdeck/runtime/scripts/Uninstall.ps1 +11 -0
- ocdeck/runtime/scripts/Verify-Windows.ps1 +13 -0
- ocdeck/runtime/scripts/check-js.py +8 -0
- ocdeck/runtime/scripts/examples/Claude-Cloud.bat +6 -0
- ocdeck/runtime/scripts/examples/Claude-Local.bat +21 -0
- ocdeck/runtime/scripts/examples/HomeAILab-Claude-5090.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-Claude-Cluster.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-5090.bat +8 -0
- ocdeck/runtime/scripts/examples/HomeAILab-OpenCode-Spark.bat +8 -0
- ocdeck/runtime/scripts/examples/OpenCode-Cloud.bat +8 -0
- ocdeck/runtime/scripts/render-gallery.py +94 -0
- ocdeck/security.py +38 -0
- ocdeck/settings.py +42 -0
- ocdeck/uninstall.py +100 -0
- 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
|
+
}
|
ocdeck/appearance_io.py
ADDED
|
@@ -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
|
+
}
|