dulus-bar 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dulus_bar/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Dulus Bar — a cross-platform Dynamic Island for your AI agents.
2
+
3
+ Free, native, no subscription. Windows / macOS (notch-aware) / Linux.
4
+ """
5
+
6
+ __version__ = "0.2.0"
dulus_bar/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """python -m dulus_bar"""
2
+
3
+ from .main import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
dulus_bar/agents.py ADDED
@@ -0,0 +1,111 @@
1
+ """Agent catalog — per-agent identity (icon + accent color) for any AI CLI.
2
+
3
+ Dulus Bar is agent-agnostic: it will happily show *any* agent name a wrapper
4
+ reports. This module just gives the well-known ones a recognizable emoji and
5
+ accent color, and hands unknown agents a stable, nicely-distributed color so
6
+ the UI still looks intentional. It is presentation-only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from dataclasses import dataclass
13
+ from typing import Dict, List, Tuple
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class AgentStyle:
18
+ key: str
19
+ display: str
20
+ emoji: str
21
+ accent: str # hex
22
+ aliases: tuple = ()
23
+
24
+
25
+ # Curated identities. `accent` roughly follows each brand.
26
+ _AGENTS: List[AgentStyle] = [
27
+ AgentStyle("dulus", "Dulus", "🦅", "#f59e0b", ("dulus", "interant")),
28
+ AgentStyle("claude", "Claude Code", "✳️", "#d97757", ("claude", "claude code", "anthropic")),
29
+ AgentStyle("codex", "Codex", "◆", "#10a37f", ("codex", "openai", "gpt")),
30
+ AgentStyle("gemini", "Gemini", "✦", "#4285f4", ("gemini", "google")),
31
+ AgentStyle("cursor", "Cursor", "▸", "#7c3aed", ("cursor",)),
32
+ AgentStyle("copilot", "Copilot", "⧉", "#6e7681", ("copilot", "github copilot")),
33
+ AgentStyle("kimi", "Kimi", "◐", "#2563eb", ("kimi", "moonshot")),
34
+ AgentStyle("deepseek", "DeepSeek", "≋", "#4d6bfe", ("deepseek",)),
35
+ AgentStyle("grok", "Grok", "𝕏", "#111827", ("grok", "xai")),
36
+ AgentStyle("qwen", "Qwen", "✺", "#615ced", ("qwen", "alibaba", "tongyi")),
37
+ AgentStyle("mistral", "Mistral", "◢", "#fa5010", ("mistral", "codestral")),
38
+ AgentStyle("windsurf", "Windsurf", "≈", "#09b6a2", ("windsurf", "codeium")),
39
+ AgentStyle("zed", "Zed", "⚡", "#084ccf", ("zed",)),
40
+ AgentStyle("aider", "Aider", "✎", "#22c55e", ("aider",)),
41
+ AgentStyle("cline", "Cline", "◔", "#0ea5e9", ("cline", "roo")),
42
+ AgentStyle("ollama", "Ollama", "◍", "#000000", ("ollama", "llama")),
43
+ ]
44
+
45
+ _BY_ALIAS: Dict[str, AgentStyle] = {}
46
+ for _a in _AGENTS:
47
+ _BY_ALIAS[_a.key] = _a
48
+ for _al in _a.aliases:
49
+ _BY_ALIAS[_al] = _a
50
+
51
+ # Fallback palette for unknown agents (kept colorblind-distinct-ish).
52
+ _FALLBACK_PALETTE = (
53
+ "#38bdf8",
54
+ "#a78bfa",
55
+ "#34d399",
56
+ "#fbbf24",
57
+ "#fb7185",
58
+ "#e879f9",
59
+ "#5eead4",
60
+ "#93c5fd",
61
+ )
62
+
63
+
64
+ def style_for(agent_name: str) -> AgentStyle:
65
+ """Return an AgentStyle for any agent name (never raises)."""
66
+ name = (agent_name or "").strip()
67
+ low = name.lower()
68
+ if low in _BY_ALIAS:
69
+ return _BY_ALIAS[low]
70
+ for alias, style in _BY_ALIAS.items():
71
+ if alias in low:
72
+ return style
73
+ # Deterministic fallback color from the name.
74
+ idx = sum(ord(c) for c in low) % len(_FALLBACK_PALETTE) if low else 0
75
+ return AgentStyle(
76
+ key=low or "agent",
77
+ display=name or "Agent",
78
+ emoji="●",
79
+ accent=_FALLBACK_PALETTE[idx],
80
+ )
81
+
82
+
83
+ def is_dulus(agent_name: str) -> bool:
84
+ return style_for(agent_name).key == "dulus"
85
+
86
+
87
+ # CLI executable names for known agents, used by "quick launch" in the UI.
88
+ # The first name found on PATH wins.
89
+ _LAUNCH_COMMANDS: Dict[str, Tuple[str, ...]] = {
90
+ "claude": ("claude",),
91
+ "codex": ("codex",),
92
+ "gemini": ("gemini",),
93
+ "cursor": ("cursor-agent", "cursor"),
94
+ "aider": ("aider",),
95
+ "qwen": ("qwen",),
96
+ "grok": ("grok",),
97
+ "copilot": ("copilot",),
98
+ "opencode": ("opencode",),
99
+ }
100
+
101
+
102
+ def detect_installed() -> List[Tuple[AgentStyle, str]]:
103
+ """Return (style, resolved_executable) for known agents found on PATH."""
104
+ found: List[Tuple[AgentStyle, str]] = []
105
+ for key, exe_names in _LAUNCH_COMMANDS.items():
106
+ for exe in exe_names:
107
+ path = shutil.which(exe)
108
+ if path:
109
+ found.append((_BY_ALIAS[key], path))
110
+ break
111
+ return found
Binary file
@@ -0,0 +1,118 @@
1
+ """Lifecycle bridge for the native SwiftUI/AppKit notch surface on macOS."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import atexit
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from .server import AgentEventServer
15
+
16
+ WS_PORT = 17372
17
+ _NATIVE_EXECUTABLE = "DulusBarNative"
18
+
19
+
20
+ def project_root() -> Path:
21
+ return Path(__file__).resolve().parent.parent
22
+
23
+
24
+ def native_binary(root: Optional[Path] = None) -> Path:
25
+ """Return the preferred release helper path."""
26
+ base = root or project_root()
27
+ override = os.environ.get("DULUS_BAR_NATIVE_BINARY")
28
+ if override:
29
+ return Path(override).expanduser().resolve()
30
+ return base / "macos" / ".build" / "release" / _NATIVE_EXECUTABLE
31
+
32
+
33
+ def native_available(root: Optional[Path] = None) -> bool:
34
+ path = native_binary(root)
35
+ return path.is_file() and os.access(path, os.X_OK)
36
+
37
+
38
+ def build_native(root: Optional[Path] = None) -> Path:
39
+ """Build the native helper and return its executable path."""
40
+ base = root or project_root()
41
+ package = base / "macos"
42
+ if not (package / "Package.swift").is_file():
43
+ raise FileNotFoundError(f"native package missing: {package}")
44
+ subprocess.run(
45
+ ["swift", "build", "-c", "release"],
46
+ cwd=package,
47
+ check=True,
48
+ )
49
+ binary = native_binary(base)
50
+ if not binary.is_file():
51
+ raise FileNotFoundError(f"native build completed without {binary}")
52
+ return binary
53
+
54
+
55
+ def _terminate(process: subprocess.Popen) -> None:
56
+ if process.poll() is not None:
57
+ return
58
+ process.terminate()
59
+ try:
60
+ process.wait(timeout=3)
61
+ except subprocess.TimeoutExpired:
62
+ process.kill()
63
+ process.wait(timeout=2)
64
+
65
+
66
+ def run_native() -> int:
67
+ """Own the WebSocket hub and run the native helper until it exits."""
68
+ binary = native_binary()
69
+ if not native_available():
70
+ if os.environ.get("DULUS_BAR_NATIVE_AUTOBUILD", "1") == "0":
71
+ raise FileNotFoundError(f"native helper not built: {binary}")
72
+ print("[dulusbar] native helper missing; building release binary...")
73
+ binary = build_native()
74
+
75
+ server = AgentEventServer(port=WS_PORT)
76
+ server.start()
77
+ process = subprocess.Popen(
78
+ [str(binary), "--websocket", f"ws://127.0.0.1:{WS_PORT}"],
79
+ cwd=project_root(),
80
+ env={**os.environ, "DULUS_BAR_PARENT_PID": str(os.getpid())},
81
+ )
82
+
83
+ cleaned = False
84
+
85
+ def cleanup() -> None:
86
+ nonlocal cleaned
87
+ if cleaned:
88
+ return
89
+ cleaned = True
90
+ _terminate(process)
91
+ server.stop()
92
+
93
+ def handle_signal(signum: int, _frame: object) -> None:
94
+ cleanup()
95
+ raise SystemExit(128 + signum)
96
+
97
+ atexit.register(cleanup)
98
+ previous_handlers = {}
99
+ for signum in (signal.SIGINT, signal.SIGTERM):
100
+ previous_handlers[signum] = signal.getsignal(signum)
101
+ signal.signal(signum, handle_signal)
102
+
103
+ try:
104
+ while True:
105
+ code = process.poll()
106
+ if code is not None:
107
+ return code
108
+ time.sleep(0.25)
109
+ finally:
110
+ cleanup()
111
+ for signum, handler in previous_handlers.items():
112
+ signal.signal(signum, handler)
113
+
114
+
115
+ def should_use_native() -> bool:
116
+ if sys.platform != "darwin":
117
+ return False
118
+ return os.environ.get("DULUS_BAR_FORCE_QT", "0").lower() not in {"1", "true", "yes"}
dulus_bar/main.py ADDED
@@ -0,0 +1,42 @@
1
+ """Entry point for Dulus Bar.
2
+
3
+ Supports both:
4
+ python -m dulus_bar
5
+ python dulus_bar/main.py
6
+ """
7
+
8
+ import os
9
+ import sys
10
+
11
+
12
+ def main():
13
+ try:
14
+ from dulus_bar.macos_native import run_native, should_use_native
15
+
16
+ if should_use_native():
17
+ try:
18
+ raise SystemExit(run_native())
19
+ except Exception as exc:
20
+ if os.environ.get("DULUS_BAR_NATIVE_REQUIRED", "0") in {"1", "true", "yes"}:
21
+ raise
22
+ print(
23
+ f"[dulusbar] native macOS surface unavailable ({exc}); using Qt fallback",
24
+ file=sys.stderr,
25
+ )
26
+
27
+ from dulus_bar.overlay import run_overlay
28
+
29
+ run_overlay()
30
+ except SystemExit:
31
+ raise
32
+ except Exception as exc:
33
+ print(f"Dulus Bar crashed: {exc}", file=sys.stderr)
34
+ raise
35
+
36
+
37
+ if __name__ == "__main__":
38
+ if __package__ is None:
39
+ # Running directly (python dulus_bar/main.py): add project root to path.
40
+ project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
41
+ sys.path.insert(0, project_root)
42
+ main()
@@ -0,0 +1,99 @@
1
+ """Cross-platform native integration layer for Dulus Bar.
2
+
3
+ Exposes a single OS-appropriate backend so the rest of the app never has to
4
+ know whether it is running on Windows, macOS or Linux. Each backend knows how
5
+ to:
6
+
7
+ * find and focus terminal / editor windows (jump-to-terminal)
8
+ * report the camera-notch geometry (macOS) so the island can hug it
9
+ * keep the overlay always-visible across Spaces / full-screen (macOS)
10
+ * pick a native-looking default UI font
11
+
12
+ Importing this package is always safe: the heavy, OS-specific imports live
13
+ inside each backend module and are guarded, so `import dulus_bar.native` works
14
+ on any platform even if the native deps are missing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from functools import lru_cache
21
+ from typing import List, Optional
22
+
23
+ from .base import NativeBackend, NotchGeometry, WindowInfo
24
+
25
+ __all__ = [
26
+ "NativeBackend",
27
+ "NotchGeometry",
28
+ "WindowInfo",
29
+ "get_backend",
30
+ "activate_window",
31
+ "find_window_by_title",
32
+ "jump_to_terminal",
33
+ "open_terminal",
34
+ "open_terminal_running",
35
+ "notch_geometry",
36
+ "configure_always_visible",
37
+ "default_font_family",
38
+ ]
39
+
40
+
41
+ @lru_cache(maxsize=1)
42
+ def get_backend() -> NativeBackend:
43
+ """Return the singleton backend for the current OS (never raises)."""
44
+ platform = sys.platform
45
+ try:
46
+ if platform.startswith("win"):
47
+ from .windows import WindowsBackend
48
+
49
+ return WindowsBackend()
50
+ if platform == "darwin":
51
+ from .macos import MacOSBackend
52
+
53
+ return MacOSBackend()
54
+ if platform.startswith("linux"):
55
+ from .linux import LinuxBackend
56
+
57
+ return LinuxBackend()
58
+ except Exception as exc: # pragma: no cover - defensive
59
+ print(f"[native] backend init failed ({platform}): {exc}; using no-op backend")
60
+ return NativeBackend()
61
+
62
+
63
+ # --- thin module-level convenience wrappers -------------------------------
64
+
65
+
66
+ def activate_window(handle: object) -> bool:
67
+ return get_backend().activate_window(handle)
68
+
69
+
70
+ def find_window_by_title(title_substring: str) -> Optional[WindowInfo]:
71
+ return get_backend().find_window_by_title(title_substring)
72
+
73
+
74
+ def jump_to_terminal(title_hint: str = "") -> bool:
75
+ return get_backend().jump_to_terminal(title_hint)
76
+
77
+
78
+ def open_terminal() -> bool:
79
+ return get_backend().open_terminal()
80
+
81
+
82
+ def open_terminal_running(command, title: str = "", cwd: Optional[str] = None) -> bool:
83
+ return get_backend().open_terminal_running(command, title=title, cwd=cwd)
84
+
85
+
86
+ def notch_geometry() -> Optional[NotchGeometry]:
87
+ return get_backend().notch_geometry()
88
+
89
+
90
+ def configure_always_visible(win: object) -> None:
91
+ get_backend().configure_always_visible(win)
92
+
93
+
94
+ def default_font_family() -> str:
95
+ return get_backend().default_font_family()
96
+
97
+
98
+ def list_windows() -> List[WindowInfo]:
99
+ return get_backend().list_windows()
@@ -0,0 +1,130 @@
1
+ """Backend interface + safe no-op fallback shared by every platform."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import List, Optional
7
+
8
+
9
+ @dataclass
10
+ class WindowInfo:
11
+ """Lightweight, platform-agnostic descriptor of a top-level window."""
12
+
13
+ handle: object # hwnd (win), window id (x11), or app pid/name (mac)
14
+ title: str
15
+ pid: int = 0
16
+ process_name: str = ""
17
+
18
+ def __repr__(self) -> str:
19
+ return f"WindowInfo({self.process_name or '?'}: {self.title!r})"
20
+
21
+
22
+ @dataclass
23
+ class NotchGeometry:
24
+ """Camera-notch rectangle in global screen pixels (macOS)."""
25
+
26
+ x: int
27
+ y: int
28
+ width: int
29
+ height: int
30
+ screen_width: int
31
+ screen_height: int
32
+
33
+
34
+ # Terminal / editor process or title fragments we consider "jumpable".
35
+ TERMINAL_HINTS = (
36
+ "windows terminal",
37
+ "powershell",
38
+ "pwsh",
39
+ "cmd.exe",
40
+ "conhost",
41
+ "wt.exe",
42
+ "alacritty",
43
+ "wezterm",
44
+ "kitty",
45
+ "tabby",
46
+ "hyper",
47
+ "conemu",
48
+ "cmder",
49
+ "iterm",
50
+ "terminal",
51
+ "gnome-terminal",
52
+ "konsole",
53
+ "xterm",
54
+ "warp",
55
+ "ghostty",
56
+ "cursor",
57
+ "visual studio code",
58
+ "code.exe",
59
+ "code",
60
+ "zed",
61
+ "windsurf",
62
+ )
63
+
64
+
65
+ class NativeBackend:
66
+ """Default backend: everything is a safe no-op.
67
+
68
+ Real platforms subclass this and override what they can do. Anything a
69
+ platform cannot do falls back to these no-ops instead of crashing.
70
+ """
71
+
72
+ name = "noop"
73
+
74
+ # --- window discovery / focus ---------------------------------------
75
+ def list_windows(self, only_visible: bool = True) -> List[WindowInfo]:
76
+ return []
77
+
78
+ def find_window_by_title(self, title_substring: str) -> Optional[WindowInfo]:
79
+ needle = title_substring.lower().strip()
80
+ if not needle:
81
+ return None
82
+ for w in self.list_windows():
83
+ if needle in w.title.lower():
84
+ return w
85
+ return None
86
+
87
+ def find_terminal_windows(self) -> List[WindowInfo]:
88
+ out: List[WindowInfo] = []
89
+ for w in self.list_windows():
90
+ haystack = f"{w.title} {w.process_name}".lower()
91
+ if any(h in haystack for h in TERMINAL_HINTS):
92
+ out.append(w)
93
+ return out
94
+
95
+ def activate_window(self, handle: object) -> bool:
96
+ return False
97
+
98
+ def jump_to_terminal(self, title_hint: str = "") -> bool:
99
+ if title_hint:
100
+ w = self.find_window_by_title(title_hint)
101
+ if w and self.activate_window(w.handle):
102
+ return True
103
+ for w in self.find_terminal_windows():
104
+ if self.activate_window(w.handle):
105
+ return True
106
+ return False
107
+
108
+ def open_terminal(self) -> bool:
109
+ return False
110
+
111
+ def open_terminal_running(self, command, title="", cwd=None) -> bool:
112
+ """Open a NEW interactive terminal window running ``command``.
113
+
114
+ ``command`` is an argv list. The agent gets a real console it can read
115
+ stdin from, which is what lets the wrapper pipe approvals. Returns False
116
+ if the platform can't spawn a terminal.
117
+ """
118
+ return False
119
+
120
+ # --- overlay placement / behavior -----------------------------------
121
+ def notch_geometry(self) -> Optional[NotchGeometry]:
122
+ """Return the camera-notch rect, or None when the display has none."""
123
+ return None
124
+
125
+ def configure_always_visible(self, win: object) -> None:
126
+ """Keep the overlay above the menu bar / on every Space (macOS)."""
127
+ return None
128
+
129
+ def default_font_family(self) -> str:
130
+ return "sans-serif"
@@ -0,0 +1,103 @@
1
+ """Linux (X11) native backend using wmctrl / xdotool when available.
2
+
3
+ Wayland exposes no portable window-activation API, so on Wayland the overlay
4
+ still shows and stays on top (handled by Qt) but jump-to-terminal degrades to a
5
+ best-effort no-op. X11 with wmctrl or xdotool installed gets full behavior.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from typing import List, Optional
13
+
14
+ from .base import NativeBackend, WindowInfo
15
+
16
+
17
+ class LinuxBackend(NativeBackend):
18
+ name = "linux"
19
+
20
+ def __init__(self) -> None:
21
+ self._wmctrl = shutil.which("wmctrl")
22
+ self._xdotool = shutil.which("xdotool")
23
+
24
+ def list_windows(self, only_visible: bool = True) -> List[WindowInfo]:
25
+ if not self._wmctrl:
26
+ return []
27
+ try:
28
+ out = subprocess.run(
29
+ [self._wmctrl, "-lp"], capture_output=True, text=True, timeout=2
30
+ ).stdout
31
+ except Exception:
32
+ return []
33
+ results: List[WindowInfo] = []
34
+ for line in out.splitlines():
35
+ # id desktop pid host title...
36
+ parts = line.split(None, 4)
37
+ if len(parts) < 5:
38
+ continue
39
+ win_id, _desktop, pid_s, _host, title = parts
40
+ try:
41
+ pid = int(pid_s)
42
+ except ValueError:
43
+ pid = 0
44
+ results.append(WindowInfo(handle=win_id, title=title, pid=pid, process_name=_proc_name(pid)))
45
+ return results
46
+
47
+ def activate_window(self, handle: object) -> bool:
48
+ win_id = str(handle)
49
+ if self._wmctrl:
50
+ try:
51
+ r = subprocess.run([self._wmctrl, "-ia", win_id], capture_output=True, timeout=2)
52
+ if r.returncode == 0:
53
+ return True
54
+ except Exception:
55
+ pass
56
+ if self._xdotool:
57
+ try:
58
+ r = subprocess.run([self._xdotool, "windowactivate", win_id], capture_output=True, timeout=2)
59
+ return r.returncode == 0
60
+ except Exception:
61
+ return False
62
+ return False
63
+
64
+ def open_terminal(self) -> bool:
65
+ for exe in ("x-terminal-emulator", "gnome-terminal", "konsole", "alacritty", "xterm"):
66
+ path = shutil.which(exe)
67
+ if path:
68
+ try:
69
+ subprocess.Popen([path])
70
+ return True
71
+ except Exception:
72
+ continue
73
+ return False
74
+
75
+ def open_terminal_running(self, command, title="", cwd=None) -> bool:
76
+ import shlex
77
+
78
+ inner = " ".join(shlex.quote(str(c)) for c in command)
79
+ # keep the shell open after the agent exits so the user sees output
80
+ bash_cmd = f"{inner}; exec bash"
81
+ for exe in ("x-terminal-emulator", "gnome-terminal", "konsole", "alacritty", "xterm", "kitty"):
82
+ path = shutil.which(exe)
83
+ if not path:
84
+ continue
85
+ try:
86
+ subprocess.Popen([path, "-e", "bash", "-c", bash_cmd], cwd=cwd)
87
+ return True
88
+ except Exception:
89
+ continue
90
+ return False
91
+
92
+ def default_font_family(self) -> str:
93
+ return "Noto Sans"
94
+
95
+
96
+ def _proc_name(pid: int) -> str:
97
+ if not pid:
98
+ return ""
99
+ try:
100
+ with open(f"/proc/{pid}/comm", "r", encoding="utf-8", errors="replace") as fh:
101
+ return fh.read().strip().lower()
102
+ except Exception:
103
+ return ""