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,599 @@
1
+ """iTerm2 terminal backend using the iTerm2 Python API.
2
+
3
+ This backend allows the Aline Dashboard to create and manage terminal tabs
4
+ directly in iTerm2, bypassing tmux for rendering. This provides native
5
+ terminal performance and features (native scrolling, copy/paste, etc.).
6
+
7
+ Requirements:
8
+ pip install iterm2
9
+
10
+ iTerm2 Setup:
11
+ 1. Enable Python API: Preferences > General > Magic > Enable Python API
12
+ 2. Grant automation permissions when prompted
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import concurrent.futures
19
+ import shutil
20
+ import time
21
+ from typing import TYPE_CHECKING
22
+
23
+ from ..terminal_backend import TerminalBackend, TerminalInfo
24
+ from ...logging_config import setup_logger
25
+
26
+ if TYPE_CHECKING:
27
+ pass
28
+
29
+ logger = setup_logger("realign.dashboard.backends.iterm2", "dashboard.log")
30
+
31
+ # Environment variable used to identify Aline-managed terminals
32
+ ENV_TERMINAL_ID = "ALINE_TERMINAL_ID"
33
+ ENV_CONTEXT_ID = "ALINE_CONTEXT_ID"
34
+ ENV_TERMINAL_PROVIDER = "ALINE_TERMINAL_PROVIDER"
35
+
36
+ # Thread pool for running iterm2 operations (iterm2 has its own event loop)
37
+ _executor = concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="iterm2")
38
+
39
+
40
+ def _run_iterm2_coroutine(coro_func):
41
+ """Run an iterm2 coroutine in a separate thread.
42
+
43
+ The iterm2 library uses its own event loop via run_until_complete(),
44
+ which conflicts with an already-running asyncio loop (like Textual's).
45
+ This function runs the iterm2 operation in a dedicated thread.
46
+
47
+ Args:
48
+ coro_func: An async function that takes (connection) as argument
49
+
50
+ Returns:
51
+ The result of the coroutine
52
+ """
53
+ import iterm2
54
+
55
+ result = None
56
+ exception = None
57
+
58
+ async def wrapper(connection):
59
+ nonlocal result, exception
60
+ try:
61
+ result = await coro_func(connection)
62
+ except Exception as e:
63
+ exception = e
64
+
65
+ try:
66
+ iterm2.run_until_complete(wrapper)
67
+ except Exception as e:
68
+ exception = e
69
+
70
+ if exception:
71
+ raise exception
72
+ return result
73
+
74
+
75
+ class ITermBackend:
76
+ """iTerm2 terminal backend using the Python API."""
77
+
78
+ def __init__(self, right_pane_session_id: str | None = None) -> None:
79
+ self._connection = None
80
+ self._terminals: dict[str, TerminalInfo] = {} # terminal_id -> TerminalInfo
81
+ self._session_to_terminal: dict[str, str] = {} # session_id -> terminal_id
82
+ self._right_pane_session_id = right_pane_session_id # Session ID for split pane mode
83
+ self._pane_sessions: list[str] = [] # Track all sessions in right pane area
84
+
85
+ def get_backend_name(self) -> str:
86
+ return "iTerm2"
87
+
88
+ async def is_available(self) -> bool:
89
+ """Check if iTerm2 and its Python API are available."""
90
+ # Check if iTerm2 is installed
91
+ if not shutil.which("osascript"):
92
+ return False
93
+
94
+ # Check if iterm2 Python package is available
95
+ try:
96
+ import iterm2 # noqa: F401
97
+
98
+ return True
99
+ except ImportError:
100
+ logger.debug("iterm2 Python package not installed")
101
+ return False
102
+
103
+ async def create_tab(
104
+ self,
105
+ command: str,
106
+ terminal_id: str,
107
+ *,
108
+ name: str | None = None,
109
+ env: dict[str, str] | None = None,
110
+ cwd: str | None = None,
111
+ ) -> str | None:
112
+ """Create a new terminal in iTerm2.
113
+
114
+ If split pane mode is active (right_pane_session_id is set), creates
115
+ terminals in the right pane area using horizontal splits.
116
+ Otherwise, creates new tabs.
117
+
118
+ Args:
119
+ command: The command to run in the new tab
120
+ terminal_id: Aline internal terminal ID
121
+ name: Optional display name for the tab
122
+ env: Optional environment variables to set
123
+ cwd: Optional working directory
124
+
125
+ Returns:
126
+ iTerm2 session ID, or None if creation failed
127
+ """
128
+ try:
129
+ import iterm2
130
+ except ImportError:
131
+ logger.error("iterm2 package not installed")
132
+ return None
133
+
134
+ created_at = time.time()
135
+
136
+ # Build environment with Aline identifiers
137
+ full_env = dict(env or {})
138
+ full_env[ENV_TERMINAL_ID] = terminal_id
139
+
140
+ # Build the command with environment variables
141
+ env_exports = " ".join(
142
+ f"export {k}={_shell_quote(v)};" for k, v in full_env.items()
143
+ )
144
+
145
+ # Build full command with cd and env
146
+ full_command = ""
147
+ if cwd:
148
+ full_command = f"cd {_shell_quote(cwd)} && "
149
+ full_command += f"{env_exports} {command}"
150
+
151
+ # Capture instance variables for closure
152
+ right_pane_id = self._right_pane_session_id
153
+ pane_sessions = self._pane_sessions
154
+
155
+ async def _create(connection) -> str | None:
156
+ app = await iterm2.async_get_app(connection)
157
+ logger.debug(f"Got app, windows count: {len(app.terminal_windows)}")
158
+
159
+ session_id = None
160
+
161
+ # Split pane mode: create in right pane area
162
+ if right_pane_id:
163
+ logger.debug(f"Split pane mode, right_pane_id={right_pane_id}")
164
+
165
+ # Find the right pane session
166
+ target_session = None
167
+ for window in app.terminal_windows:
168
+ for tab in window.tabs:
169
+ for session in tab.sessions:
170
+ if session.session_id == right_pane_id:
171
+ target_session = session
172
+ break
173
+ if target_session:
174
+ break
175
+ if target_session:
176
+ break
177
+
178
+ if target_session:
179
+ # If this is the first terminal and right pane is empty, use it directly
180
+ if not pane_sessions:
181
+ logger.debug("Using existing right pane for first terminal")
182
+ session_id = target_session.session_id
183
+ # Send command to existing session
184
+ try:
185
+ await target_session.async_send_text(full_command + "\n")
186
+ except Exception as e:
187
+ logger.error(f"Exception sending command: {e}")
188
+ else:
189
+ # Create a new horizontal split in the right pane area
190
+ logger.debug("Creating horizontal split in right pane")
191
+ try:
192
+ profile = iterm2.LocalWriteOnlyProfile()
193
+ if name:
194
+ profile.set_name(name)
195
+ profile.set_allow_title_setting(False)
196
+
197
+ new_session = await target_session.async_split_pane(
198
+ vertical=False, # Horizontal split (stack vertically)
199
+ profile_customizations=profile,
200
+ )
201
+ session_id = new_session.session_id
202
+
203
+ # Send command
204
+ await new_session.async_send_text(full_command + "\n")
205
+ except Exception as e:
206
+ logger.error(f"Exception creating split: {e}")
207
+ return None
208
+ else:
209
+ logger.warning(f"Right pane session {right_pane_id} not found, falling back to tab mode")
210
+
211
+ # Fallback: create new tab
212
+ if session_id is None:
213
+ window = app.current_terminal_window
214
+
215
+ if window is None:
216
+ logger.debug("No current window, creating new one...")
217
+ try:
218
+ window = await iterm2.Window.async_create(connection)
219
+ except Exception as e:
220
+ logger.error(f"Exception creating window: {type(e).__name__}: {e}")
221
+ return None
222
+ if window is None:
223
+ logger.error("Failed to create iTerm2 window (returned None)")
224
+ return None
225
+
226
+ # Create profile customizations for the tab name
227
+ profile = iterm2.LocalWriteOnlyProfile()
228
+ if name:
229
+ profile.set_name(name)
230
+ profile.set_allow_title_setting(False)
231
+
232
+ # Create the tab
233
+ logger.debug("Creating tab...")
234
+ try:
235
+ tab = await window.async_create_tab(
236
+ profile_customizations=profile,
237
+ )
238
+ except Exception as e:
239
+ logger.error(f"Exception creating tab: {type(e).__name__}: {e}")
240
+ return None
241
+
242
+ if tab is None:
243
+ logger.error("Failed to create iTerm2 tab (returned None)")
244
+ return None
245
+
246
+ session = tab.current_session
247
+ if session is None:
248
+ logger.error("Tab created but no current session")
249
+ return None
250
+
251
+ session_id = session.session_id
252
+
253
+ # Send the command
254
+ logger.debug(f"Sending command: {full_command[:100]}...")
255
+ try:
256
+ await session.async_send_text(full_command + "\n")
257
+ except Exception as e:
258
+ logger.error(f"Exception sending command: {type(e).__name__}: {e}")
259
+
260
+ logger.info(
261
+ f"Created iTerm2 terminal: session_id={session_id}, terminal_id={terminal_id}"
262
+ )
263
+ return session_id
264
+
265
+ try:
266
+ loop = asyncio.get_event_loop()
267
+ session_id = await loop.run_in_executor(_executor, _run_iterm2_coroutine, _create)
268
+ except Exception as e:
269
+ import traceback
270
+ logger.error(f"Failed to create iTerm2 tab: {type(e).__name__}: {e}\n{traceback.format_exc()}")
271
+ return None
272
+
273
+ if session_id:
274
+ # Track the terminal
275
+ info = TerminalInfo(
276
+ terminal_id=terminal_id,
277
+ session_id=session_id,
278
+ name=name or "Terminal",
279
+ active=True,
280
+ provider=env.get(ENV_TERMINAL_PROVIDER) if env else None,
281
+ context_id=env.get(ENV_CONTEXT_ID) if env else None,
282
+ created_at=created_at,
283
+ )
284
+ self._terminals[terminal_id] = info
285
+ self._session_to_terminal[session_id] = terminal_id
286
+ self._pane_sessions.append(session_id)
287
+
288
+ return session_id
289
+
290
+ async def focus_tab(self, session_id: str, *, steal_focus: bool = False) -> bool:
291
+ """Switch to a terminal tab without stealing window focus.
292
+
293
+ Args:
294
+ session_id: iTerm2 session ID
295
+ steal_focus: If True, also bring iTerm2 window to front
296
+
297
+ Returns:
298
+ True if successful
299
+ """
300
+ try:
301
+ import iterm2
302
+ except ImportError:
303
+ return False
304
+
305
+ async def _focus(connection) -> bool:
306
+ app = await iterm2.async_get_app(connection)
307
+
308
+ # Find the session and its tab
309
+ for window in app.terminal_windows:
310
+ for tab in window.tabs:
311
+ if tab.current_session.session_id == session_id:
312
+ # Select the tab (this doesn't activate the window)
313
+ await tab.async_select()
314
+
315
+ # Only activate window if steal_focus is True
316
+ if steal_focus:
317
+ await window.async_activate()
318
+
319
+ logger.debug(
320
+ f"Focused iTerm2 tab: session_id={session_id}, steal_focus={steal_focus}"
321
+ )
322
+ return True
323
+
324
+ logger.warning(f"Session not found: {session_id}")
325
+ return False
326
+
327
+ try:
328
+ loop = asyncio.get_event_loop()
329
+ success = await loop.run_in_executor(_executor, _run_iterm2_coroutine, _focus)
330
+ return success or False
331
+ except Exception as e:
332
+ logger.error(f"Failed to focus iTerm2 tab: {e}")
333
+ return False
334
+
335
+ async def close_tab(self, session_id: str) -> bool:
336
+ """Close a terminal tab.
337
+
338
+ Args:
339
+ session_id: iTerm2 session ID
340
+
341
+ Returns:
342
+ True if successful
343
+ """
344
+ try:
345
+ import iterm2
346
+ except ImportError:
347
+ return False
348
+
349
+ async def _close(connection) -> bool:
350
+ app = await iterm2.async_get_app(connection)
351
+
352
+ for window in app.terminal_windows:
353
+ for tab in window.tabs:
354
+ session = tab.current_session
355
+ if session.session_id == session_id:
356
+ await session.async_close()
357
+ logger.info(f"Closed iTerm2 session: {session_id}")
358
+ return True
359
+
360
+ logger.warning(f"Session not found for close: {session_id}")
361
+ return False
362
+
363
+ try:
364
+ loop = asyncio.get_event_loop()
365
+ success = await loop.run_in_executor(_executor, _run_iterm2_coroutine, _close)
366
+ except Exception as e:
367
+ logger.error(f"Failed to close iTerm2 tab: {e}")
368
+ return False
369
+
370
+ # Clean up tracking
371
+ if success and session_id in self._session_to_terminal:
372
+ terminal_id = self._session_to_terminal.pop(session_id)
373
+ self._terminals.pop(terminal_id, None)
374
+
375
+ return success or False
376
+
377
+ async def list_tabs(self) -> list[TerminalInfo]:
378
+ """List all Aline-managed terminal tabs in iTerm2.
379
+
380
+ Returns:
381
+ List of TerminalInfo for managed tabs
382
+ """
383
+ try:
384
+ import iterm2
385
+ except ImportError:
386
+ return []
387
+
388
+ # Capture self reference for closure
389
+ session_to_terminal = self._session_to_terminal
390
+ terminals = self._terminals
391
+
392
+ async def _list(connection) -> list[TerminalInfo]:
393
+ tabs: list[TerminalInfo] = []
394
+ app = await iterm2.async_get_app(connection)
395
+
396
+ # Get the currently focused session
397
+ focused_session_id = None
398
+ if app.current_terminal_window:
399
+ current_tab = app.current_terminal_window.current_tab
400
+ if current_tab:
401
+ focused_session_id = current_tab.current_session.session_id
402
+
403
+ for window in app.terminal_windows:
404
+ for tab in window.tabs:
405
+ session = tab.current_session
406
+ sess_id = session.session_id
407
+
408
+ # Check if this is an Aline-managed terminal
409
+ term_id = session_to_terminal.get(sess_id)
410
+ if term_id and term_id in terminals:
411
+ info = terminals[term_id]
412
+ # Update active state
413
+ tabs.append(
414
+ TerminalInfo(
415
+ terminal_id=info.terminal_id,
416
+ session_id=sess_id,
417
+ name=info.name,
418
+ active=(sess_id == focused_session_id),
419
+ claude_session_id=info.claude_session_id,
420
+ context_id=info.context_id,
421
+ provider=info.provider,
422
+ attention=info.attention,
423
+ created_at=info.created_at,
424
+ metadata=info.metadata,
425
+ )
426
+ )
427
+ return tabs
428
+
429
+ try:
430
+ loop = asyncio.get_event_loop()
431
+ tabs = await loop.run_in_executor(_executor, _run_iterm2_coroutine, _list)
432
+ if tabs is None:
433
+ tabs = []
434
+ except Exception as e:
435
+ logger.error(f"Failed to list iTerm2 tabs: {e}")
436
+ return []
437
+
438
+ # Sort by creation time (newest first)
439
+ tabs.sort(
440
+ key=lambda t: t.created_at if t.created_at is not None else 0, reverse=True
441
+ )
442
+ return tabs
443
+
444
+ def update_terminal_info(
445
+ self, terminal_id: str, **kwargs: str | None
446
+ ) -> bool:
447
+ """Update metadata for a tracked terminal.
448
+
449
+ Args:
450
+ terminal_id: Aline internal terminal ID
451
+ **kwargs: Fields to update (claude_session_id, context_id, provider, attention, etc.)
452
+
453
+ Returns:
454
+ True if terminal was found and updated
455
+ """
456
+ if terminal_id not in self._terminals:
457
+ return False
458
+
459
+ info = self._terminals[terminal_id]
460
+
461
+ # Update allowed fields
462
+ if "claude_session_id" in kwargs:
463
+ self._terminals[terminal_id] = TerminalInfo(
464
+ terminal_id=info.terminal_id,
465
+ session_id=info.session_id,
466
+ name=info.name,
467
+ active=info.active,
468
+ claude_session_id=kwargs.get("claude_session_id") or info.claude_session_id,
469
+ context_id=kwargs.get("context_id") or info.context_id,
470
+ provider=kwargs.get("provider") or info.provider,
471
+ attention=kwargs.get("attention"), # Allow clearing attention
472
+ created_at=info.created_at,
473
+ metadata=info.metadata,
474
+ )
475
+ return True
476
+
477
+
478
+ def _shell_quote(s: str) -> str:
479
+ """Quote a string for shell use."""
480
+ import shlex
481
+
482
+ return shlex.quote(s)
483
+
484
+
485
+ async def setup_split_pane_layout(dashboard_width_percent: int = 40) -> str | None:
486
+ """Set up a split pane layout with dashboard on left, terminals on right.
487
+
488
+ This should be called BEFORE the dashboard starts. It will:
489
+ 1. Get the current session (where the command is running)
490
+ 2. Split it vertically, creating a right pane for terminals
491
+ 3. Return the right pane's session ID for later use
492
+
493
+ Args:
494
+ dashboard_width_percent: Percentage of width for dashboard (left pane)
495
+
496
+ Returns:
497
+ Session ID of the right pane (for terminals), or None if failed
498
+ """
499
+ try:
500
+ import iterm2
501
+ except ImportError:
502
+ return None
503
+
504
+ right_session_id: str | None = None
505
+
506
+ async def _setup(connection) -> str | None:
507
+ nonlocal right_session_id
508
+ app = await iterm2.async_get_app(connection)
509
+
510
+ window = app.current_terminal_window
511
+ if not window:
512
+ logger.error("No current window for split pane setup")
513
+ return None
514
+
515
+ tab = window.current_tab
516
+ if not tab:
517
+ logger.error("No current tab for split pane setup")
518
+ return None
519
+
520
+ session = tab.current_session
521
+ if not session:
522
+ logger.error("No current session for split pane setup")
523
+ return None
524
+
525
+ # Split vertically - new pane will be on the right
526
+ try:
527
+ right_session = await session.async_split_pane(vertical=True)
528
+ right_session_id = right_session.session_id
529
+ logger.info(f"Created right pane: {right_session_id}")
530
+
531
+ # Activate the left pane (dashboard) so user sees it
532
+ await session.async_activate()
533
+
534
+ return right_session_id
535
+ except Exception as e:
536
+ logger.error(f"Failed to split pane: {e}")
537
+ return None
538
+
539
+ try:
540
+ loop = asyncio.get_event_loop()
541
+ result = await loop.run_in_executor(_executor, _run_iterm2_coroutine, _setup)
542
+ return result
543
+ except Exception as e:
544
+ logger.error(f"Failed to setup split pane layout: {e}")
545
+ return None
546
+
547
+
548
+ def setup_split_pane_layout_sync(dashboard_width_percent: int = 40) -> str | None:
549
+ """Synchronous version of setup_split_pane_layout for use before asyncio loop starts.
550
+
551
+ Returns:
552
+ Session ID of the right pane, or None if failed
553
+ """
554
+ try:
555
+ import iterm2
556
+ except ImportError:
557
+ return None
558
+
559
+ right_session_id: str | None = None
560
+
561
+ async def _setup(connection) -> str | None:
562
+ nonlocal right_session_id
563
+ app = await iterm2.async_get_app(connection)
564
+
565
+ window = app.current_terminal_window
566
+ if not window:
567
+ logger.error("No current window for split pane setup")
568
+ return None
569
+
570
+ tab = window.current_tab
571
+ if not tab:
572
+ logger.error("No current tab for split pane setup")
573
+ return None
574
+
575
+ session = tab.current_session
576
+ if not session:
577
+ logger.error("No current session for split pane setup")
578
+ return None
579
+
580
+ # Split vertically - new pane will be on the right
581
+ try:
582
+ right_session = await session.async_split_pane(vertical=True)
583
+ right_session_id = right_session.session_id
584
+ logger.info(f"Created right pane: {right_session_id}")
585
+
586
+ # Activate the left pane (dashboard) so user sees it
587
+ await session.async_activate()
588
+
589
+ return right_session_id
590
+ except Exception as e:
591
+ logger.error(f"Failed to split pane: {e}")
592
+ return None
593
+
594
+ try:
595
+ iterm2.run_until_complete(_setup)
596
+ return right_session_id
597
+ except Exception as e:
598
+ logger.error(f"Failed to setup split pane layout: {e}")
599
+ return None