aline-ai 0.5.13__py3-none-any.whl → 0.6.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.
- {aline_ai-0.5.13.dist-info → aline_ai-0.6.1.dist-info}/METADATA +1 -1
- {aline_ai-0.5.13.dist-info → aline_ai-0.6.1.dist-info}/RECORD +24 -19
- realign/__init__.py +1 -1
- realign/auth.py +21 -0
- realign/cli.py +293 -6
- realign/commands/auth.py +110 -0
- realign/commands/import_shares.py +6 -0
- realign/commands/watcher.py +8 -0
- realign/commands/worker.py +8 -0
- realign/dashboard/app.py +68 -6
- realign/dashboard/backends/__init__.py +6 -0
- realign/dashboard/backends/iterm2.py +599 -0
- realign/dashboard/backends/kitty.py +372 -0
- realign/dashboard/layout.py +320 -0
- realign/dashboard/terminal_backend.py +110 -0
- realign/dashboard/widgets/events_table.py +44 -5
- realign/dashboard/widgets/sessions_table.py +76 -16
- realign/dashboard/widgets/terminal_panel.py +566 -104
- realign/watcher_daemon.py +11 -0
- realign/worker_daemon.py +11 -0
- {aline_ai-0.5.13.dist-info → aline_ai-0.6.1.dist-info}/WHEEL +0 -0
- {aline_ai-0.5.13.dist-info → aline_ai-0.6.1.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.5.13.dist-info → aline_ai-0.6.1.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.5.13.dist-info → aline_ai-0.6.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Kitty terminal backend using the remote control protocol.
|
|
2
|
+
|
|
3
|
+
This backend allows the Aline Dashboard to create and manage terminal tabs
|
|
4
|
+
directly in Kitty, bypassing tmux for rendering. This provides native
|
|
5
|
+
terminal performance and features.
|
|
6
|
+
|
|
7
|
+
Requirements:
|
|
8
|
+
Kitty must be configured with remote control enabled:
|
|
9
|
+
|
|
10
|
+
In ~/.config/kitty/kitty.conf:
|
|
11
|
+
allow_remote_control yes
|
|
12
|
+
listen_on unix:/tmp/kitty_aline
|
|
13
|
+
|
|
14
|
+
Or start kitty with:
|
|
15
|
+
kitty --listen-on unix:/tmp/kitty_aline
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import time
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from ..terminal_backend import TerminalBackend, TerminalInfo
|
|
29
|
+
from ...logging_config import setup_logger
|
|
30
|
+
|
|
31
|
+
logger = setup_logger("realign.dashboard.backends.kitty", "dashboard.log")
|
|
32
|
+
|
|
33
|
+
# Environment variable used to identify Aline-managed terminals
|
|
34
|
+
ENV_TERMINAL_ID = "ALINE_TERMINAL_ID"
|
|
35
|
+
ENV_CONTEXT_ID = "ALINE_CONTEXT_ID"
|
|
36
|
+
ENV_TERMINAL_PROVIDER = "ALINE_TERMINAL_PROVIDER"
|
|
37
|
+
|
|
38
|
+
# Default socket path for Kitty remote control
|
|
39
|
+
DEFAULT_SOCKET = "/tmp/kitty_aline"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class KittyBackend:
|
|
43
|
+
"""Kitty terminal backend using remote control."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, socket: str = DEFAULT_SOCKET) -> None:
|
|
46
|
+
self.socket = socket
|
|
47
|
+
self._terminals: dict[str, TerminalInfo] = {} # terminal_id -> TerminalInfo
|
|
48
|
+
self._window_to_terminal: dict[str, str] = {} # kitty window_id -> terminal_id
|
|
49
|
+
|
|
50
|
+
def get_backend_name(self) -> str:
|
|
51
|
+
return "Kitty"
|
|
52
|
+
|
|
53
|
+
async def is_available(self) -> bool:
|
|
54
|
+
"""Check if Kitty and remote control are available."""
|
|
55
|
+
# Check if kitty is installed
|
|
56
|
+
if not shutil.which("kitty"):
|
|
57
|
+
logger.debug("Kitty not found in PATH")
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# Check if kitten (Kitty's helper) is available
|
|
61
|
+
if not shutil.which("kitten"):
|
|
62
|
+
logger.debug("kitten command not found")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
# Check if the socket exists (Kitty is running with remote control)
|
|
66
|
+
if not os.path.exists(self.socket):
|
|
67
|
+
logger.debug(f"Kitty socket not found: {self.socket}")
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
# Try to connect
|
|
71
|
+
result = await self._run_kitten("ls")
|
|
72
|
+
return result is not None
|
|
73
|
+
|
|
74
|
+
async def _run_kitten(self, *args: str) -> dict[str, Any] | list[Any] | None:
|
|
75
|
+
"""Run a kitten @ command and return JSON output.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
*args: Arguments to pass to kitten @
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
Parsed JSON response, or None on error
|
|
82
|
+
"""
|
|
83
|
+
cmd = ["kitten", "@", "--to", f"unix:{self.socket}", *args]
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
proc = await asyncio.create_subprocess_exec(
|
|
87
|
+
*cmd,
|
|
88
|
+
stdout=asyncio.subprocess.PIPE,
|
|
89
|
+
stderr=asyncio.subprocess.PIPE,
|
|
90
|
+
)
|
|
91
|
+
stdout, stderr = await proc.communicate()
|
|
92
|
+
|
|
93
|
+
if proc.returncode != 0:
|
|
94
|
+
if stderr:
|
|
95
|
+
logger.debug(f"kitten error: {stderr.decode()}")
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
if stdout:
|
|
99
|
+
try:
|
|
100
|
+
return json.loads(stdout.decode())
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
# Some commands return non-JSON output
|
|
103
|
+
return {"output": stdout.decode().strip()}
|
|
104
|
+
|
|
105
|
+
return {}
|
|
106
|
+
|
|
107
|
+
except Exception as e:
|
|
108
|
+
logger.error(f"Failed to run kitten command: {e}")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
async def _run_kitten_simple(self, *args: str) -> str | None:
|
|
112
|
+
"""Run a kitten @ command and return raw output.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
*args: Arguments to pass to kitten @
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Raw stdout, or None on error
|
|
119
|
+
"""
|
|
120
|
+
cmd = ["kitten", "@", "--to", f"unix:{self.socket}", *args]
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
proc = await asyncio.create_subprocess_exec(
|
|
124
|
+
*cmd,
|
|
125
|
+
stdout=asyncio.subprocess.PIPE,
|
|
126
|
+
stderr=asyncio.subprocess.PIPE,
|
|
127
|
+
)
|
|
128
|
+
stdout, stderr = await proc.communicate()
|
|
129
|
+
|
|
130
|
+
if proc.returncode != 0:
|
|
131
|
+
if stderr:
|
|
132
|
+
logger.debug(f"kitten error: {stderr.decode()}")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
return stdout.decode().strip() if stdout else ""
|
|
136
|
+
|
|
137
|
+
except Exception as e:
|
|
138
|
+
logger.error(f"Failed to run kitten command: {e}")
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
async def create_tab(
|
|
142
|
+
self,
|
|
143
|
+
command: str,
|
|
144
|
+
terminal_id: str,
|
|
145
|
+
*,
|
|
146
|
+
name: str | None = None,
|
|
147
|
+
env: dict[str, str] | None = None,
|
|
148
|
+
cwd: str | None = None,
|
|
149
|
+
) -> str | None:
|
|
150
|
+
"""Create a new tab in Kitty.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
command: The command to run in the new tab
|
|
154
|
+
terminal_id: Aline internal terminal ID
|
|
155
|
+
name: Optional display name for the tab
|
|
156
|
+
env: Optional environment variables to set
|
|
157
|
+
cwd: Optional working directory
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
Kitty window ID (as string), or None if creation failed
|
|
161
|
+
"""
|
|
162
|
+
created_at = time.time()
|
|
163
|
+
|
|
164
|
+
# Build environment with Aline identifiers
|
|
165
|
+
full_env = dict(env or {})
|
|
166
|
+
full_env[ENV_TERMINAL_ID] = terminal_id
|
|
167
|
+
|
|
168
|
+
# Build kitten launch arguments
|
|
169
|
+
args = ["launch", "--type=tab"]
|
|
170
|
+
|
|
171
|
+
# Add environment variables
|
|
172
|
+
for key, value in full_env.items():
|
|
173
|
+
args.extend(["--env", f"{key}={value}"])
|
|
174
|
+
|
|
175
|
+
# Add working directory
|
|
176
|
+
if cwd:
|
|
177
|
+
args.extend(["--cwd", cwd])
|
|
178
|
+
|
|
179
|
+
# Add tab title
|
|
180
|
+
if name:
|
|
181
|
+
args.extend(["--tab-title", name])
|
|
182
|
+
|
|
183
|
+
# Add the command (as a shell command)
|
|
184
|
+
args.append(f"/bin/zsh -lc {_shell_quote(command)}")
|
|
185
|
+
|
|
186
|
+
# Run the launch command
|
|
187
|
+
result = await self._run_kitten_simple(*args)
|
|
188
|
+
|
|
189
|
+
if result is None:
|
|
190
|
+
logger.error("Failed to create Kitty tab")
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
# The launch command returns the window ID
|
|
194
|
+
window_id = result.strip()
|
|
195
|
+
if not window_id:
|
|
196
|
+
logger.error("No window ID returned from Kitty launch")
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
logger.info(
|
|
200
|
+
f"Created Kitty tab: window_id={window_id}, terminal_id={terminal_id}"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Track the terminal
|
|
204
|
+
info = TerminalInfo(
|
|
205
|
+
terminal_id=terminal_id,
|
|
206
|
+
session_id=window_id,
|
|
207
|
+
name=name or "Terminal",
|
|
208
|
+
active=True,
|
|
209
|
+
provider=full_env.get(ENV_TERMINAL_PROVIDER),
|
|
210
|
+
context_id=full_env.get(ENV_CONTEXT_ID),
|
|
211
|
+
created_at=created_at,
|
|
212
|
+
)
|
|
213
|
+
self._terminals[terminal_id] = info
|
|
214
|
+
self._window_to_terminal[window_id] = terminal_id
|
|
215
|
+
|
|
216
|
+
return window_id
|
|
217
|
+
|
|
218
|
+
async def focus_tab(self, session_id: str, *, steal_focus: bool = False) -> bool:
|
|
219
|
+
"""Switch to a terminal tab.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
session_id: Kitty window ID
|
|
223
|
+
steal_focus: If True, also bring Kitty window to front
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
True if successful
|
|
227
|
+
"""
|
|
228
|
+
# Focus the window/tab
|
|
229
|
+
result = await self._run_kitten_simple(
|
|
230
|
+
"focus-window", "--match", f"id:{session_id}"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if result is None:
|
|
234
|
+
logger.warning(f"Failed to focus Kitty window: {session_id}")
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
logger.debug(f"Focused Kitty tab: window_id={session_id}")
|
|
238
|
+
|
|
239
|
+
# If not stealing focus, try to return focus to the previous app
|
|
240
|
+
# Note: Kitty doesn't have a direct way to do this, so we rely on
|
|
241
|
+
# the window manager. On macOS, we could use AppleScript.
|
|
242
|
+
if not steal_focus:
|
|
243
|
+
# Best effort: the focus-window command in Kitty typically
|
|
244
|
+
# doesn't steal app focus, just switches the active tab
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
return True
|
|
248
|
+
|
|
249
|
+
async def close_tab(self, session_id: str) -> bool:
|
|
250
|
+
"""Close a terminal tab.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
session_id: Kitty window ID
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
True if successful
|
|
257
|
+
"""
|
|
258
|
+
result = await self._run_kitten_simple(
|
|
259
|
+
"close-window", "--match", f"id:{session_id}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if result is None:
|
|
263
|
+
logger.warning(f"Failed to close Kitty window: {session_id}")
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
logger.info(f"Closed Kitty window: {session_id}")
|
|
267
|
+
|
|
268
|
+
# Clean up tracking
|
|
269
|
+
if session_id in self._window_to_terminal:
|
|
270
|
+
terminal_id = self._window_to_terminal.pop(session_id)
|
|
271
|
+
self._terminals.pop(terminal_id, None)
|
|
272
|
+
|
|
273
|
+
return True
|
|
274
|
+
|
|
275
|
+
async def list_tabs(self) -> list[TerminalInfo]:
|
|
276
|
+
"""List all Aline-managed terminal tabs in Kitty.
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
List of TerminalInfo for managed tabs
|
|
280
|
+
"""
|
|
281
|
+
result = await self._run_kitten("ls")
|
|
282
|
+
|
|
283
|
+
if result is None:
|
|
284
|
+
return []
|
|
285
|
+
|
|
286
|
+
tabs: list[TerminalInfo] = []
|
|
287
|
+
|
|
288
|
+
# Parse the Kitty ls output
|
|
289
|
+
# Structure: [{"id": os_window_id, "tabs": [{"id": tab_id, "windows": [...]}]}]
|
|
290
|
+
try:
|
|
291
|
+
os_windows = result if isinstance(result, list) else []
|
|
292
|
+
|
|
293
|
+
for os_window in os_windows:
|
|
294
|
+
if not isinstance(os_window, dict):
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
for tab in os_window.get("tabs", []):
|
|
298
|
+
if not isinstance(tab, dict):
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
for window in tab.get("windows", []):
|
|
302
|
+
if not isinstance(window, dict):
|
|
303
|
+
continue
|
|
304
|
+
|
|
305
|
+
window_id = str(window.get("id", ""))
|
|
306
|
+
is_focused = window.get("is_focused", False)
|
|
307
|
+
|
|
308
|
+
# Check if this is an Aline-managed terminal
|
|
309
|
+
terminal_id = self._window_to_terminal.get(window_id)
|
|
310
|
+
if terminal_id and terminal_id in self._terminals:
|
|
311
|
+
info = self._terminals[terminal_id]
|
|
312
|
+
tabs.append(
|
|
313
|
+
TerminalInfo(
|
|
314
|
+
terminal_id=info.terminal_id,
|
|
315
|
+
session_id=window_id,
|
|
316
|
+
name=info.name,
|
|
317
|
+
active=is_focused,
|
|
318
|
+
claude_session_id=info.claude_session_id,
|
|
319
|
+
context_id=info.context_id,
|
|
320
|
+
provider=info.provider,
|
|
321
|
+
attention=info.attention,
|
|
322
|
+
created_at=info.created_at,
|
|
323
|
+
metadata=info.metadata,
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
except Exception as e:
|
|
328
|
+
logger.error(f"Failed to parse Kitty ls output: {e}")
|
|
329
|
+
return []
|
|
330
|
+
|
|
331
|
+
# Sort by creation time (newest first)
|
|
332
|
+
tabs.sort(
|
|
333
|
+
key=lambda t: t.created_at if t.created_at is not None else 0, reverse=True
|
|
334
|
+
)
|
|
335
|
+
return tabs
|
|
336
|
+
|
|
337
|
+
def update_terminal_info(
|
|
338
|
+
self, terminal_id: str, **kwargs: str | None
|
|
339
|
+
) -> bool:
|
|
340
|
+
"""Update metadata for a tracked terminal.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
terminal_id: Aline internal terminal ID
|
|
344
|
+
**kwargs: Fields to update
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
True if terminal was found and updated
|
|
348
|
+
"""
|
|
349
|
+
if terminal_id not in self._terminals:
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
info = self._terminals[terminal_id]
|
|
353
|
+
self._terminals[terminal_id] = TerminalInfo(
|
|
354
|
+
terminal_id=info.terminal_id,
|
|
355
|
+
session_id=info.session_id,
|
|
356
|
+
name=info.name,
|
|
357
|
+
active=info.active,
|
|
358
|
+
claude_session_id=kwargs.get("claude_session_id") or info.claude_session_id,
|
|
359
|
+
context_id=kwargs.get("context_id") or info.context_id,
|
|
360
|
+
provider=kwargs.get("provider") or info.provider,
|
|
361
|
+
attention=kwargs.get("attention"), # Allow clearing attention
|
|
362
|
+
created_at=info.created_at,
|
|
363
|
+
metadata=info.metadata,
|
|
364
|
+
)
|
|
365
|
+
return True
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _shell_quote(s: str) -> str:
|
|
369
|
+
"""Quote a string for shell use."""
|
|
370
|
+
import shlex
|
|
371
|
+
|
|
372
|
+
return shlex.quote(s)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Window layout utilities for native terminal mode.
|
|
2
|
+
|
|
3
|
+
This module provides functions to arrange the Aline Dashboard and native
|
|
4
|
+
terminal windows (iTerm2/Kitty) in a side-by-side layout.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from ..logging_config import setup_logger
|
|
14
|
+
|
|
15
|
+
logger = setup_logger("realign.dashboard.layout", "dashboard.log")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_screen_size() -> tuple[int, int]:
|
|
19
|
+
"""Get the main screen size on macOS.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
(width, height) in pixels
|
|
23
|
+
"""
|
|
24
|
+
if sys.platform != "darwin":
|
|
25
|
+
# Default fallback for non-macOS
|
|
26
|
+
return (1920, 1080)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
# Use system_profiler to get display resolution
|
|
30
|
+
result = subprocess.run(
|
|
31
|
+
[
|
|
32
|
+
"system_profiler",
|
|
33
|
+
"SPDisplaysDataType",
|
|
34
|
+
"-json",
|
|
35
|
+
],
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
timeout=5,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if result.returncode == 0 and result.stdout:
|
|
42
|
+
import json
|
|
43
|
+
|
|
44
|
+
data = json.loads(result.stdout)
|
|
45
|
+
displays = data.get("SPDisplaysDataType", [])
|
|
46
|
+
for display in displays:
|
|
47
|
+
for monitor in display.get("spdisplays_ndrvs", []):
|
|
48
|
+
resolution = monitor.get("_spdisplays_resolution", "")
|
|
49
|
+
# Parse resolution like "2560 x 1440"
|
|
50
|
+
if " x " in resolution:
|
|
51
|
+
parts = resolution.split(" x ")
|
|
52
|
+
try:
|
|
53
|
+
width = int(parts[0].strip())
|
|
54
|
+
height = int(parts[1].split()[0].strip())
|
|
55
|
+
return (width, height)
|
|
56
|
+
except (ValueError, IndexError):
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.debug(f"Failed to get screen size via system_profiler: {e}")
|
|
61
|
+
|
|
62
|
+
# Fallback: try AppKit
|
|
63
|
+
try:
|
|
64
|
+
import AppKit
|
|
65
|
+
|
|
66
|
+
screen = AppKit.NSScreen.mainScreen()
|
|
67
|
+
if screen:
|
|
68
|
+
frame = screen.frame()
|
|
69
|
+
return (int(frame.size.width), int(frame.size.height))
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logger.debug(f"Failed to get screen size via AppKit: {e}")
|
|
72
|
+
|
|
73
|
+
# Default fallback
|
|
74
|
+
return (1920, 1080)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def setup_side_by_side_layout(
|
|
78
|
+
terminal_app: str = "iTerm2",
|
|
79
|
+
dashboard_on_left: bool = True,
|
|
80
|
+
dashboard_width_percent: int = 50,
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""Set up side-by-side layout for Dashboard and terminal windows.
|
|
83
|
+
|
|
84
|
+
Arranges the Dashboard and terminal app windows to fill the screen
|
|
85
|
+
side by side, without using full-screen mode.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
terminal_app: Terminal application name ("iTerm2" or "Kitty")
|
|
89
|
+
dashboard_on_left: If True, Dashboard on left, terminal on right
|
|
90
|
+
dashboard_width_percent: Percentage of screen width for Dashboard (default 50)
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
True if layout was set successfully
|
|
94
|
+
"""
|
|
95
|
+
if sys.platform != "darwin":
|
|
96
|
+
logger.warning("Side-by-side layout only supported on macOS")
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
width, height = get_screen_size()
|
|
100
|
+
menu_bar_height = 25 # Approximate macOS menu bar height
|
|
101
|
+
|
|
102
|
+
dashboard_width = int(width * dashboard_width_percent / 100)
|
|
103
|
+
terminal_width = width - dashboard_width
|
|
104
|
+
|
|
105
|
+
if dashboard_on_left:
|
|
106
|
+
dashboard_x = 0
|
|
107
|
+
terminal_x = dashboard_width
|
|
108
|
+
else:
|
|
109
|
+
terminal_x = 0
|
|
110
|
+
dashboard_x = terminal_width
|
|
111
|
+
|
|
112
|
+
# Get the hosting terminal app (the one running the Dashboard)
|
|
113
|
+
hosting_app = _detect_hosting_terminal()
|
|
114
|
+
|
|
115
|
+
success = True
|
|
116
|
+
|
|
117
|
+
# Position the hosting terminal (Dashboard)
|
|
118
|
+
if hosting_app:
|
|
119
|
+
if not _set_window_bounds(
|
|
120
|
+
hosting_app,
|
|
121
|
+
dashboard_x,
|
|
122
|
+
menu_bar_height,
|
|
123
|
+
dashboard_x + dashboard_width,
|
|
124
|
+
height,
|
|
125
|
+
):
|
|
126
|
+
success = False
|
|
127
|
+
|
|
128
|
+
# Position the target terminal app (for the terminal tabs)
|
|
129
|
+
if terminal_app and terminal_app != hosting_app:
|
|
130
|
+
if not _set_window_bounds(
|
|
131
|
+
terminal_app,
|
|
132
|
+
terminal_x,
|
|
133
|
+
menu_bar_height,
|
|
134
|
+
terminal_x + terminal_width,
|
|
135
|
+
height,
|
|
136
|
+
):
|
|
137
|
+
success = False
|
|
138
|
+
|
|
139
|
+
return success
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _detect_hosting_terminal() -> str | None:
|
|
143
|
+
"""Detect which terminal app is hosting the current process.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Terminal app name ("Terminal", "iTerm2", "Kitty") or None
|
|
147
|
+
"""
|
|
148
|
+
term_program = os.environ.get("TERM_PROGRAM", "").strip()
|
|
149
|
+
|
|
150
|
+
if term_program in {"Apple_Terminal", "Terminal.app"}:
|
|
151
|
+
return "Terminal"
|
|
152
|
+
if term_program in {"iTerm.app", "iTerm2"} or term_program.startswith("iTerm"):
|
|
153
|
+
return "iTerm2"
|
|
154
|
+
if term_program == "kitty":
|
|
155
|
+
return "Kitty"
|
|
156
|
+
|
|
157
|
+
# Try to detect via parent process
|
|
158
|
+
try:
|
|
159
|
+
import psutil
|
|
160
|
+
|
|
161
|
+
proc = psutil.Process()
|
|
162
|
+
for parent in proc.parents():
|
|
163
|
+
name = parent.name().lower()
|
|
164
|
+
if "iterm" in name:
|
|
165
|
+
return "iTerm2"
|
|
166
|
+
if "terminal" in name:
|
|
167
|
+
return "Terminal"
|
|
168
|
+
if "kitty" in name:
|
|
169
|
+
return "Kitty"
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _set_window_bounds(
|
|
177
|
+
app_name: str, x1: int, y1: int, x2: int, y2: int
|
|
178
|
+
) -> bool:
|
|
179
|
+
"""Set window bounds for an application using AppleScript.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
app_name: Application name
|
|
183
|
+
x1, y1: Top-left corner coordinates
|
|
184
|
+
x2, y2: Bottom-right corner coordinates
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
True if successful
|
|
188
|
+
"""
|
|
189
|
+
if sys.platform != "darwin":
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
# Map app names to their AppleScript names
|
|
193
|
+
script_app_name = app_name
|
|
194
|
+
if app_name == "Kitty":
|
|
195
|
+
# Kitty doesn't support AppleScript well, use alternative method
|
|
196
|
+
return _set_kitty_window_bounds(x1, y1, x2, y2)
|
|
197
|
+
|
|
198
|
+
# Build AppleScript
|
|
199
|
+
script = f'''
|
|
200
|
+
tell application "{script_app_name}"
|
|
201
|
+
if (count of windows) > 0 then
|
|
202
|
+
set bounds of front window to {{{x1}, {y1}, {x2}, {y2}}}
|
|
203
|
+
end if
|
|
204
|
+
end tell
|
|
205
|
+
'''
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
result = subprocess.run(
|
|
209
|
+
["osascript", "-e", script],
|
|
210
|
+
capture_output=True,
|
|
211
|
+
text=True,
|
|
212
|
+
timeout=5,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if result.returncode != 0:
|
|
216
|
+
logger.warning(
|
|
217
|
+
f"Failed to set {app_name} window bounds: {result.stderr}"
|
|
218
|
+
)
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
logger.debug(f"Set {app_name} window bounds: ({x1}, {y1}, {x2}, {y2})")
|
|
222
|
+
return True
|
|
223
|
+
|
|
224
|
+
except Exception as e:
|
|
225
|
+
logger.error(f"Failed to set {app_name} window bounds: {e}")
|
|
226
|
+
return False
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _set_kitty_window_bounds(x1: int, y1: int, x2: int, y2: int) -> bool:
|
|
230
|
+
"""Set Kitty window bounds using its remote control.
|
|
231
|
+
|
|
232
|
+
Kitty doesn't support AppleScript, so we use its native resize command.
|
|
233
|
+
However, Kitty's remote control doesn't support window positioning,
|
|
234
|
+
so we fall back to AppleScript via System Events.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
x1, y1: Top-left corner coordinates
|
|
238
|
+
x2, y2: Bottom-right corner coordinates
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
True if successful
|
|
242
|
+
"""
|
|
243
|
+
# Try using System Events (requires accessibility permissions)
|
|
244
|
+
script = f'''
|
|
245
|
+
tell application "System Events"
|
|
246
|
+
tell process "kitty"
|
|
247
|
+
if (count of windows) > 0 then
|
|
248
|
+
set position of front window to {{{x1}, {y1}}}
|
|
249
|
+
set size of front window to {{{x2 - x1}, {y2 - y1}}}
|
|
250
|
+
end if
|
|
251
|
+
end tell
|
|
252
|
+
end tell
|
|
253
|
+
'''
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
result = subprocess.run(
|
|
257
|
+
["osascript", "-e", script],
|
|
258
|
+
capture_output=True,
|
|
259
|
+
text=True,
|
|
260
|
+
timeout=5,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
if result.returncode != 0:
|
|
264
|
+
logger.warning(f"Failed to set Kitty window bounds: {result.stderr}")
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
logger.debug(f"Set Kitty window bounds: ({x1}, {y1}, {x2}, {y2})")
|
|
268
|
+
return True
|
|
269
|
+
|
|
270
|
+
except Exception as e:
|
|
271
|
+
logger.error(f"Failed to set Kitty window bounds: {e}")
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def bring_app_to_front(app_name: str) -> bool:
|
|
276
|
+
"""Bring an application to the front.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
app_name: Application name
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
True if successful
|
|
283
|
+
"""
|
|
284
|
+
if sys.platform != "darwin":
|
|
285
|
+
return False
|
|
286
|
+
|
|
287
|
+
script = f'''
|
|
288
|
+
tell application "{app_name}"
|
|
289
|
+
activate
|
|
290
|
+
end tell
|
|
291
|
+
'''
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
result = subprocess.run(
|
|
295
|
+
["osascript", "-e", script],
|
|
296
|
+
capture_output=True,
|
|
297
|
+
text=True,
|
|
298
|
+
timeout=2,
|
|
299
|
+
)
|
|
300
|
+
return result.returncode == 0
|
|
301
|
+
except Exception:
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def focus_window_without_raising(app_name: str) -> bool:
|
|
306
|
+
"""Focus an application's window without bringing it to the front.
|
|
307
|
+
|
|
308
|
+
This is tricky on macOS - we want to switch the active tab/window
|
|
309
|
+
within the app without stealing focus from the current app.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
app_name: Application name
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
True if successful (best effort)
|
|
316
|
+
"""
|
|
317
|
+
# This is inherently difficult on macOS. The best we can do is
|
|
318
|
+
# use the terminal backend's focus_tab with steal_focus=False.
|
|
319
|
+
# This function is here for API completeness.
|
|
320
|
+
return True
|