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 +6 -0
- dulus_bar/__main__.py +6 -0
- dulus_bar/agents.py +111 -0
- dulus_bar/assets/dulus-bird.png +0 -0
- dulus_bar/macos_native.py +118 -0
- dulus_bar/main.py +42 -0
- dulus_bar/native/__init__.py +99 -0
- dulus_bar/native/base.py +130 -0
- dulus_bar/native/linux.py +103 -0
- dulus_bar/native/macos.py +205 -0
- dulus_bar/native/windows.py +97 -0
- dulus_bar/overlay.py +1099 -0
- dulus_bar/server.py +193 -0
- dulus_bar-0.3.0.dist-info/METADATA +212 -0
- dulus_bar-0.3.0.dist-info/RECORD +19 -0
- dulus_bar-0.3.0.dist-info/WHEEL +5 -0
- dulus_bar-0.3.0.dist-info/entry_points.txt +2 -0
- dulus_bar-0.3.0.dist-info/licenses/LICENSE +21 -0
- dulus_bar-0.3.0.dist-info/top_level.txt +1 -0
dulus_bar/__init__.py
ADDED
dulus_bar/__main__.py
ADDED
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()
|
dulus_bar/native/base.py
ADDED
|
@@ -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 ""
|