aline-ai 0.6.0__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.
@@ -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