aline-ai 0.6.0__py3-none-any.whl → 0.6.2__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.6.0.dist-info → aline_ai-0.6.2.dist-info}/METADATA +1 -1
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.2.dist-info}/RECORD +25 -20
- realign/__init__.py +1 -1
- realign/auth.py +21 -0
- realign/claude_hooks/stop_hook.py +35 -0
- realign/claude_hooks/user_prompt_submit_hook.py +5 -0
- realign/cli.py +76 -34
- realign/commands/auth.py +9 -0
- realign/dashboard/app.py +69 -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/screens/create_agent.py +41 -4
- realign/dashboard/terminal_backend.py +110 -0
- realign/dashboard/tmux_manager.py +17 -0
- realign/dashboard/widgets/terminal_panel.py +587 -110
- realign/db/sqlite_db.py +18 -0
- realign/events/session_summarizer.py +17 -2
- realign/watcher_core.py +56 -22
- realign/worker_core.py +2 -0
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.2.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.2.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.2.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.2.dist-info}/top_level.txt +0 -0
|
@@ -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
|