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.
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.1.dist-info}/METADATA +1 -1
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.1.dist-info}/RECORD +17 -12
- realign/__init__.py +1 -1
- realign/auth.py +21 -0
- realign/cli.py +44 -6
- realign/commands/auth.py +9 -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/terminal_panel.py +566 -104
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.1.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.1.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.1.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.6.0.dist-info → aline_ai-0.6.1.dist-info}/top_level.txt +0 -0
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
"""Terminal controls panel
|
|
1
|
+
"""Terminal controls panel with native terminal and tmux support.
|
|
2
2
|
|
|
3
|
-
This panel
|
|
4
|
-
-
|
|
5
|
-
|
|
3
|
+
This panel controls terminal tabs in either:
|
|
4
|
+
1. Native terminals (iTerm2/Kitty) - for better performance with high-frequency updates
|
|
5
|
+
2. tmux - the traditional approach with embedded terminal rendering
|
|
6
|
+
|
|
7
|
+
The mode is determined by the ALINE_TERMINAL_MODE environment variable:
|
|
8
|
+
- "native" or "iterm2" or "kitty": Use native terminal backend
|
|
9
|
+
- "tmux" or unset: Use tmux backend (default)
|
|
6
10
|
"""
|
|
7
11
|
|
|
8
12
|
from __future__ import annotations
|
|
@@ -13,7 +17,7 @@ import re
|
|
|
13
17
|
import shlex
|
|
14
18
|
import traceback
|
|
15
19
|
from pathlib import Path
|
|
16
|
-
from typing import Callable
|
|
20
|
+
from typing import Callable, Union
|
|
17
21
|
|
|
18
22
|
from textual.app import ComposeResult
|
|
19
23
|
from textual.containers import Container, Horizontal, Vertical, VerticalScroll
|
|
@@ -22,6 +26,7 @@ from textual.widgets import Button, Static
|
|
|
22
26
|
from rich.text import Text
|
|
23
27
|
|
|
24
28
|
from .. import tmux_manager
|
|
29
|
+
from ..terminal_backend import TerminalBackend, TerminalInfo
|
|
25
30
|
from ...logging_config import setup_logger
|
|
26
31
|
|
|
27
32
|
logger = setup_logger("realign.dashboard.terminal", "dashboard.log")
|
|
@@ -30,6 +35,19 @@ logger = setup_logger("realign.dashboard.terminal", "dashboard.log")
|
|
|
30
35
|
# Signal directory for permission request notifications
|
|
31
36
|
PERMISSION_SIGNAL_DIR = Path.home() / ".aline" / ".signals" / "permission_request"
|
|
32
37
|
|
|
38
|
+
# Environment variable to control terminal mode
|
|
39
|
+
ENV_TERMINAL_MODE = "ALINE_TERMINAL_MODE"
|
|
40
|
+
|
|
41
|
+
# Terminal mode constants
|
|
42
|
+
MODE_TMUX = "tmux"
|
|
43
|
+
MODE_NATIVE = "native"
|
|
44
|
+
MODE_ITERM2 = "iterm2"
|
|
45
|
+
MODE_KITTY = "kitty"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Type for window data (either tmux InnerWindow or native TerminalInfo)
|
|
49
|
+
WindowData = Union[tmux_manager.InnerWindow, TerminalInfo]
|
|
50
|
+
|
|
33
51
|
|
|
34
52
|
class _SignalFileWatcher:
|
|
35
53
|
"""Watches for new signal files in the permission_request directory.
|
|
@@ -128,8 +146,75 @@ class _SignalFileWatcher:
|
|
|
128
146
|
pass
|
|
129
147
|
|
|
130
148
|
|
|
149
|
+
def _detect_terminal_mode() -> str:
|
|
150
|
+
"""Detect which terminal mode to use.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Terminal mode string (MODE_TMUX, MODE_ITERM2, or MODE_KITTY)
|
|
154
|
+
"""
|
|
155
|
+
mode = os.environ.get(ENV_TERMINAL_MODE, "").strip().lower()
|
|
156
|
+
|
|
157
|
+
if mode in {MODE_ITERM2, "iterm"}:
|
|
158
|
+
return MODE_ITERM2
|
|
159
|
+
if mode == MODE_KITTY:
|
|
160
|
+
return MODE_KITTY
|
|
161
|
+
if mode == MODE_NATIVE:
|
|
162
|
+
# Auto-detect best native terminal
|
|
163
|
+
term_program = os.environ.get("TERM_PROGRAM", "").strip()
|
|
164
|
+
if term_program in {"iTerm.app", "iTerm2"} or term_program.startswith("iTerm"):
|
|
165
|
+
return MODE_ITERM2
|
|
166
|
+
if term_program == "kitty":
|
|
167
|
+
return MODE_KITTY
|
|
168
|
+
# Default to iTerm2 on macOS
|
|
169
|
+
import sys
|
|
170
|
+
if sys.platform == "darwin":
|
|
171
|
+
return MODE_ITERM2
|
|
172
|
+
return MODE_TMUX
|
|
173
|
+
|
|
174
|
+
# Default to tmux
|
|
175
|
+
return MODE_TMUX
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def _get_native_backend(mode: str) -> TerminalBackend | None:
|
|
179
|
+
"""Get the appropriate native terminal backend.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
mode: Terminal mode (MODE_ITERM2 or MODE_KITTY)
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Backend instance if available, None otherwise
|
|
186
|
+
"""
|
|
187
|
+
if mode == MODE_ITERM2:
|
|
188
|
+
try:
|
|
189
|
+
from ..backends.iterm2 import ITermBackend
|
|
190
|
+
|
|
191
|
+
# Check for split pane session ID from environment
|
|
192
|
+
right_pane_session_id = os.environ.get("ALINE_ITERM2_RIGHT_PANE")
|
|
193
|
+
if right_pane_session_id:
|
|
194
|
+
logger.debug(f"Using split pane mode with right pane: {right_pane_session_id}")
|
|
195
|
+
|
|
196
|
+
backend = ITermBackend(right_pane_session_id=right_pane_session_id)
|
|
197
|
+
if await backend.is_available():
|
|
198
|
+
return backend
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.debug(f"iTerm2 backend not available: {e}")
|
|
201
|
+
elif mode == MODE_KITTY:
|
|
202
|
+
try:
|
|
203
|
+
from ..backends.kitty import KittyBackend
|
|
204
|
+
backend = KittyBackend()
|
|
205
|
+
if await backend.is_available():
|
|
206
|
+
return backend
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.debug(f"Kitty backend not available: {e}")
|
|
209
|
+
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
131
213
|
class TerminalPanel(Container, can_focus=True):
|
|
132
|
-
"""Terminal controls panel with permission request notifications.
|
|
214
|
+
"""Terminal controls panel with permission request notifications.
|
|
215
|
+
|
|
216
|
+
Supports both native terminal backends (iTerm2/Kitty) and tmux.
|
|
217
|
+
"""
|
|
133
218
|
|
|
134
219
|
class PermissionRequestDetected(Message):
|
|
135
220
|
"""Posted when a new permission request signal file is detected."""
|
|
@@ -258,42 +343,83 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
258
343
|
|
|
259
344
|
@staticmethod
|
|
260
345
|
def supported() -> bool:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
)
|
|
346
|
+
"""Check if terminal controls are supported.
|
|
347
|
+
|
|
348
|
+
Supports both native terminal mode and tmux mode.
|
|
349
|
+
"""
|
|
350
|
+
mode = _detect_terminal_mode()
|
|
351
|
+
|
|
352
|
+
if mode == MODE_TMUX:
|
|
353
|
+
return (
|
|
354
|
+
tmux_manager.tmux_available()
|
|
355
|
+
and tmux_manager.in_tmux()
|
|
356
|
+
and tmux_manager.managed_env_enabled()
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
# For native mode, we check availability asynchronously in refresh_data
|
|
360
|
+
# Here we just return True if native mode is requested
|
|
361
|
+
return mode in {MODE_ITERM2, MODE_KITTY}
|
|
266
362
|
|
|
267
363
|
@staticmethod
|
|
268
364
|
def _support_message() -> str:
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
if
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
365
|
+
mode = _detect_terminal_mode()
|
|
366
|
+
|
|
367
|
+
if mode == MODE_TMUX:
|
|
368
|
+
if not tmux_manager.tmux_available():
|
|
369
|
+
return "tmux not installed. Run `aline add tmux`, then restart `aline`."
|
|
370
|
+
if not tmux_manager.in_tmux():
|
|
371
|
+
return "Not running inside tmux. Restart with `aline` to enable terminal controls."
|
|
372
|
+
if not tmux_manager.managed_env_enabled():
|
|
373
|
+
return "Not in an Aline-managed tmux session. Start via `aline` to enable terminal controls."
|
|
374
|
+
elif mode == MODE_ITERM2:
|
|
375
|
+
return "iTerm2 Python API not available. Install with: pip install iterm2"
|
|
376
|
+
elif mode == MODE_KITTY:
|
|
377
|
+
return "Kitty remote control not available. Configure listen_on in kitty.conf"
|
|
378
|
+
|
|
275
379
|
return ""
|
|
276
380
|
|
|
277
381
|
@staticmethod
|
|
278
|
-
def _is_claude_window(w:
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
382
|
+
def _is_claude_window(w: WindowData) -> bool:
|
|
383
|
+
"""Check if a window is a Claude terminal."""
|
|
384
|
+
if isinstance(w, TerminalInfo):
|
|
385
|
+
# Native terminal: check provider
|
|
386
|
+
return w.provider == "claude"
|
|
387
|
+
|
|
388
|
+
# tmux: check window name and tags
|
|
282
389
|
window_name = (w.window_name or "").strip().lower()
|
|
283
390
|
if re.fullmatch(r"cc(?:-\d+)?", window_name or ""):
|
|
284
391
|
return True
|
|
285
392
|
|
|
286
|
-
# Fallback: treat as Claude only when it looks Aline-managed (terminal_id/context_id),
|
|
287
|
-
# not merely because a hook tagged the window.
|
|
288
393
|
is_claude_tagged = (w.provider == "claude") or (w.session_type == "claude")
|
|
289
394
|
return bool(is_claude_tagged and (w.terminal_id or w.context_id))
|
|
290
395
|
|
|
291
|
-
def __init__(self) -> None:
|
|
396
|
+
def __init__(self, use_native_terminal: bool | None = None) -> None:
|
|
397
|
+
"""Initialize the terminal panel.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
use_native_terminal: If True, use native terminal backend.
|
|
401
|
+
If False, use tmux.
|
|
402
|
+
If None (default), auto-detect from environment.
|
|
403
|
+
"""
|
|
292
404
|
super().__init__()
|
|
293
405
|
self._refresh_lock = asyncio.Lock()
|
|
294
406
|
self._expanded_window_id: str | None = None
|
|
295
407
|
self._signal_watcher: _SignalFileWatcher | None = None
|
|
296
408
|
|
|
409
|
+
# Determine terminal mode
|
|
410
|
+
if use_native_terminal is True:
|
|
411
|
+
self._mode = _detect_terminal_mode()
|
|
412
|
+
if self._mode == MODE_TMUX:
|
|
413
|
+
self._mode = MODE_ITERM2 # Default to iTerm2 if native requested
|
|
414
|
+
elif use_native_terminal is False:
|
|
415
|
+
self._mode = MODE_TMUX
|
|
416
|
+
else:
|
|
417
|
+
self._mode = _detect_terminal_mode()
|
|
418
|
+
|
|
419
|
+
# Native backend (initialized lazily)
|
|
420
|
+
self._native_backend: TerminalBackend | None = None
|
|
421
|
+
self._native_backend_checked = False
|
|
422
|
+
|
|
297
423
|
def compose(self) -> ComposeResult:
|
|
298
424
|
logger.debug("TerminalPanel.compose() started")
|
|
299
425
|
try:
|
|
@@ -318,9 +444,6 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
318
444
|
raise
|
|
319
445
|
|
|
320
446
|
def on_show(self) -> None:
|
|
321
|
-
# Don't `await refresh_data()` directly here: Textual may do an initial layout pass with
|
|
322
|
-
# width=0 while a widget becomes visible, and Rich can raise when wrapping text at width 0.
|
|
323
|
-
# Scheduling after the next refresh avoids a hard crash ("flash exit") when entering the tab.
|
|
324
447
|
self.call_after_refresh(
|
|
325
448
|
lambda: self.run_worker(
|
|
326
449
|
self.refresh_data(),
|
|
@@ -328,11 +451,9 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
328
451
|
exclusive=True,
|
|
329
452
|
)
|
|
330
453
|
)
|
|
331
|
-
# Start watching for permission request signals
|
|
332
454
|
self._start_signal_watcher()
|
|
333
455
|
|
|
334
456
|
def on_hide(self) -> None:
|
|
335
|
-
# Stop watching when panel is hidden
|
|
336
457
|
self._stop_signal_watcher()
|
|
337
458
|
|
|
338
459
|
def _start_signal_watcher(self) -> None:
|
|
@@ -350,7 +471,6 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
350
471
|
|
|
351
472
|
def _on_permission_signal(self) -> None:
|
|
352
473
|
"""Called when a new permission request signal is detected."""
|
|
353
|
-
# Post message to trigger refresh on the main thread
|
|
354
474
|
self.post_message(self.PermissionRequestDetected())
|
|
355
475
|
|
|
356
476
|
def on_terminal_panel_permission_request_detected(
|
|
@@ -363,52 +483,135 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
363
483
|
exclusive=True,
|
|
364
484
|
)
|
|
365
485
|
|
|
486
|
+
async def _ensure_native_backend(self) -> TerminalBackend | None:
|
|
487
|
+
"""Ensure native backend is initialized."""
|
|
488
|
+
if self._native_backend_checked:
|
|
489
|
+
return self._native_backend
|
|
490
|
+
|
|
491
|
+
self._native_backend_checked = True
|
|
492
|
+
|
|
493
|
+
if self._mode in {MODE_ITERM2, MODE_KITTY}:
|
|
494
|
+
self._native_backend = await _get_native_backend(self._mode)
|
|
495
|
+
|
|
496
|
+
return self._native_backend
|
|
497
|
+
|
|
498
|
+
def _is_native_mode(self) -> bool:
|
|
499
|
+
"""Check if we're using native terminal mode."""
|
|
500
|
+
return self._mode in {MODE_ITERM2, MODE_KITTY}
|
|
501
|
+
|
|
366
502
|
async def refresh_data(self) -> None:
|
|
367
503
|
async with self._refresh_lock:
|
|
504
|
+
if self._is_native_mode():
|
|
505
|
+
await self._refresh_native_data()
|
|
506
|
+
else:
|
|
507
|
+
await self._refresh_tmux_data()
|
|
508
|
+
|
|
509
|
+
async def _refresh_native_data(self) -> None:
|
|
510
|
+
"""Refresh data using native terminal backend."""
|
|
511
|
+
backend = await self._ensure_native_backend()
|
|
512
|
+
if not backend:
|
|
513
|
+
# Fall back to showing error message
|
|
368
514
|
try:
|
|
369
|
-
|
|
515
|
+
container = self.query_one("#terminals", Vertical)
|
|
516
|
+
await container.remove_children()
|
|
517
|
+
await container.mount(Static(self._support_message()))
|
|
370
518
|
except Exception:
|
|
371
|
-
|
|
519
|
+
pass
|
|
520
|
+
return
|
|
372
521
|
|
|
373
|
-
|
|
374
|
-
|
|
522
|
+
try:
|
|
523
|
+
windows = await backend.list_tabs()
|
|
524
|
+
except Exception as e:
|
|
525
|
+
logger.error(f"Failed to list native terminals: {e}")
|
|
526
|
+
return
|
|
375
527
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if self._expanded_window_id and self._expanded_window_id != active_window_id:
|
|
382
|
-
self._expanded_window_id = None
|
|
383
|
-
claude_ids = [
|
|
384
|
-
w.session_id for w in windows if self._is_claude_window(w) and w.session_id
|
|
385
|
-
]
|
|
386
|
-
titles = self._fetch_claude_session_titles(claude_ids)
|
|
387
|
-
|
|
388
|
-
context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
|
|
389
|
-
all_context_session_ids: set[str] = set()
|
|
390
|
-
for w in windows:
|
|
391
|
-
if not self._is_claude_window(w) or not w.context_id:
|
|
392
|
-
continue
|
|
393
|
-
session_ids, session_count, event_count = self._get_loaded_context_info(
|
|
394
|
-
w.context_id
|
|
395
|
-
)
|
|
396
|
-
if not session_ids and session_count == 0 and event_count == 0:
|
|
397
|
-
continue
|
|
398
|
-
context_info_by_context_id[w.context_id] = (
|
|
399
|
-
session_ids,
|
|
400
|
-
session_count,
|
|
401
|
-
event_count,
|
|
402
|
-
)
|
|
403
|
-
all_context_session_ids.update(session_ids)
|
|
528
|
+
active_window_id = next(
|
|
529
|
+
(w.session_id for w in windows if w.active), None
|
|
530
|
+
)
|
|
531
|
+
if self._expanded_window_id and self._expanded_window_id != active_window_id:
|
|
532
|
+
self._expanded_window_id = None
|
|
404
533
|
|
|
405
|
-
|
|
406
|
-
|
|
534
|
+
# Get Claude session IDs for title lookup
|
|
535
|
+
claude_ids = [
|
|
536
|
+
w.claude_session_id for w in windows
|
|
537
|
+
if self._is_claude_window(w) and w.claude_session_id
|
|
538
|
+
]
|
|
539
|
+
titles = self._fetch_claude_session_titles(claude_ids)
|
|
407
540
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
541
|
+
# Get context info
|
|
542
|
+
context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
|
|
543
|
+
all_context_session_ids: set[str] = set()
|
|
544
|
+
for w in windows:
|
|
545
|
+
if not self._is_claude_window(w) or not w.context_id:
|
|
546
|
+
continue
|
|
547
|
+
session_ids, session_count, event_count = self._get_loaded_context_info(
|
|
548
|
+
w.context_id
|
|
549
|
+
)
|
|
550
|
+
if not session_ids and session_count == 0 and event_count == 0:
|
|
551
|
+
continue
|
|
552
|
+
context_info_by_context_id[w.context_id] = (
|
|
553
|
+
session_ids,
|
|
554
|
+
session_count,
|
|
555
|
+
event_count,
|
|
556
|
+
)
|
|
557
|
+
all_context_session_ids.update(session_ids)
|
|
558
|
+
|
|
559
|
+
if all_context_session_ids:
|
|
560
|
+
titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
|
|
561
|
+
|
|
562
|
+
try:
|
|
563
|
+
await self._render_terminals_native(windows, titles, context_info_by_context_id)
|
|
564
|
+
except Exception:
|
|
565
|
+
return
|
|
566
|
+
|
|
567
|
+
async def _refresh_tmux_data(self) -> None:
|
|
568
|
+
"""Refresh data using tmux backend."""
|
|
569
|
+
try:
|
|
570
|
+
supported = self.supported()
|
|
571
|
+
except Exception:
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
if not supported:
|
|
575
|
+
return
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
windows = tmux_manager.list_inner_windows()
|
|
579
|
+
except Exception:
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
active_window_id = next((w.window_id for w in windows if w.active), None)
|
|
583
|
+
if self._expanded_window_id and self._expanded_window_id != active_window_id:
|
|
584
|
+
self._expanded_window_id = None
|
|
585
|
+
|
|
586
|
+
claude_ids = [
|
|
587
|
+
w.session_id for w in windows if self._is_claude_window(w) and w.session_id
|
|
588
|
+
]
|
|
589
|
+
titles = self._fetch_claude_session_titles(claude_ids)
|
|
590
|
+
|
|
591
|
+
context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
|
|
592
|
+
all_context_session_ids: set[str] = set()
|
|
593
|
+
for w in windows:
|
|
594
|
+
if not self._is_claude_window(w) or not w.context_id:
|
|
595
|
+
continue
|
|
596
|
+
session_ids, session_count, event_count = self._get_loaded_context_info(
|
|
597
|
+
w.context_id
|
|
598
|
+
)
|
|
599
|
+
if not session_ids and session_count == 0 and event_count == 0:
|
|
600
|
+
continue
|
|
601
|
+
context_info_by_context_id[w.context_id] = (
|
|
602
|
+
session_ids,
|
|
603
|
+
session_count,
|
|
604
|
+
event_count,
|
|
605
|
+
)
|
|
606
|
+
all_context_session_ids.update(session_ids)
|
|
607
|
+
|
|
608
|
+
if all_context_session_ids:
|
|
609
|
+
titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
|
|
610
|
+
|
|
611
|
+
try:
|
|
612
|
+
await self._render_terminals_tmux(windows, titles, context_info_by_context_id)
|
|
613
|
+
except Exception:
|
|
614
|
+
return
|
|
412
615
|
|
|
413
616
|
def _fetch_claude_session_titles(self, session_ids: list[str]) -> dict[str, str]:
|
|
414
617
|
if not session_ids:
|
|
@@ -428,10 +631,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
428
631
|
return {}
|
|
429
632
|
|
|
430
633
|
def _get_loaded_context_info(self, context_id: str) -> tuple[list[str], int, int]:
|
|
431
|
-
"""Best-effort: read ~/.aline/load.json for a context_id, and return its session ids.
|
|
432
|
-
|
|
433
|
-
Also expands any context event ids to their constituent sessions (when DB is available).
|
|
434
|
-
"""
|
|
634
|
+
"""Best-effort: read ~/.aline/load.json for a context_id, and return its session ids."""
|
|
435
635
|
context_id = (context_id or "").strip()
|
|
436
636
|
if not context_id:
|
|
437
637
|
return ([], 0, 0)
|
|
@@ -473,12 +673,103 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
473
673
|
except Exception:
|
|
474
674
|
return ([], 0, 0)
|
|
475
675
|
|
|
476
|
-
async def
|
|
676
|
+
async def _render_terminals_native(
|
|
677
|
+
self,
|
|
678
|
+
windows: list[TerminalInfo],
|
|
679
|
+
titles: dict[str, str],
|
|
680
|
+
context_info_by_context_id: dict[str, tuple[list[str], int, int]],
|
|
681
|
+
) -> None:
|
|
682
|
+
"""Render terminal list for native backend."""
|
|
683
|
+
container = self.query_one("#terminals", Vertical)
|
|
684
|
+
await container.remove_children()
|
|
685
|
+
|
|
686
|
+
if not windows:
|
|
687
|
+
await container.mount(
|
|
688
|
+
Static("No terminals yet. Click 'Create' to open a new agent terminal.")
|
|
689
|
+
)
|
|
690
|
+
return
|
|
691
|
+
|
|
692
|
+
for w in windows:
|
|
693
|
+
safe = self._safe_id_fragment(w.session_id)
|
|
694
|
+
row = Horizontal(classes="terminal-row")
|
|
695
|
+
await container.mount(row)
|
|
696
|
+
|
|
697
|
+
if w.attention:
|
|
698
|
+
await row.mount(Static("●", classes="attention-dot"))
|
|
699
|
+
|
|
700
|
+
switch_classes = "terminal-switch active" if w.active else "terminal-switch"
|
|
701
|
+
loaded_ids: list[str] = []
|
|
702
|
+
raw_sessions = 0
|
|
703
|
+
raw_events = 0
|
|
704
|
+
if self._is_claude_window(w) and w.context_id:
|
|
705
|
+
loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
|
|
706
|
+
w.context_id, ([], 0, 0)
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
label = self._window_label_native(w, titles, raw_sessions, raw_events)
|
|
710
|
+
await row.mount(
|
|
711
|
+
Button(
|
|
712
|
+
label,
|
|
713
|
+
id=f"switch-{safe}",
|
|
714
|
+
name=w.session_id,
|
|
715
|
+
classes=switch_classes,
|
|
716
|
+
)
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
can_toggle_ctx = bool(
|
|
720
|
+
self._is_claude_window(w) and w.context_id and (raw_sessions or raw_events)
|
|
721
|
+
)
|
|
722
|
+
expanded = bool(w.active and w.session_id == self._expanded_window_id)
|
|
723
|
+
if w.active and can_toggle_ctx:
|
|
724
|
+
await row.mount(
|
|
725
|
+
Button(
|
|
726
|
+
"▼" if expanded else "▶",
|
|
727
|
+
id=f"toggle-{safe}",
|
|
728
|
+
name=w.session_id,
|
|
729
|
+
variant="default",
|
|
730
|
+
classes="terminal-toggle",
|
|
731
|
+
)
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
await row.mount(
|
|
735
|
+
Button(
|
|
736
|
+
"✕",
|
|
737
|
+
id=f"close-{safe}",
|
|
738
|
+
name=w.session_id,
|
|
739
|
+
variant="error",
|
|
740
|
+
classes="terminal-close",
|
|
741
|
+
)
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
if w.active and self._is_claude_window(w) and w.context_id and expanded:
|
|
745
|
+
ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
|
|
746
|
+
await container.mount(ctx)
|
|
747
|
+
if loaded_ids:
|
|
748
|
+
for idx, sid in enumerate(loaded_ids):
|
|
749
|
+
title = titles.get(sid, "").strip() or "(no title)"
|
|
750
|
+
await ctx.mount(
|
|
751
|
+
Button(
|
|
752
|
+
f"{title} ({self._short_id(sid)})",
|
|
753
|
+
id=f"ctxsess-{safe}-{idx}",
|
|
754
|
+
name=sid,
|
|
755
|
+
variant="default",
|
|
756
|
+
classes="context-session",
|
|
757
|
+
)
|
|
758
|
+
)
|
|
759
|
+
else:
|
|
760
|
+
await ctx.mount(
|
|
761
|
+
Static(
|
|
762
|
+
"[dim]Context loaded, but session list isn't available (events not expanded).[/dim]"
|
|
763
|
+
)
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
async def _render_terminals_tmux(
|
|
477
767
|
self,
|
|
478
768
|
windows: list[tmux_manager.InnerWindow],
|
|
479
769
|
titles: dict[str, str],
|
|
480
770
|
context_info_by_context_id: dict[str, tuple[list[str], int, int]],
|
|
481
771
|
) -> None:
|
|
772
|
+
"""Render terminal list for tmux backend."""
|
|
482
773
|
container = self.query_one("#terminals", Vertical)
|
|
483
774
|
await container.remove_children()
|
|
484
775
|
|
|
@@ -503,7 +794,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
503
794
|
loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
|
|
504
795
|
w.context_id, ([], 0, 0)
|
|
505
796
|
)
|
|
506
|
-
label = self.
|
|
797
|
+
label = self._window_label_tmux(w, titles, raw_sessions, raw_events)
|
|
507
798
|
await row.mount(
|
|
508
799
|
Button(
|
|
509
800
|
label,
|
|
@@ -528,7 +819,6 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
528
819
|
)
|
|
529
820
|
await row.mount(
|
|
530
821
|
Button(
|
|
531
|
-
# Avoid Rich crash when measured with width=0.
|
|
532
822
|
"✕",
|
|
533
823
|
id=f"close-{safe}",
|
|
534
824
|
name=w.window_id,
|
|
@@ -570,13 +860,45 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
570
860
|
return "ctx 0"
|
|
571
861
|
return "ctx " + " ".join(parts)
|
|
572
862
|
|
|
573
|
-
def
|
|
863
|
+
def _window_label_native(
|
|
864
|
+
self,
|
|
865
|
+
w: TerminalInfo,
|
|
866
|
+
titles: dict[str, str],
|
|
867
|
+
raw_sessions: int = 0,
|
|
868
|
+
raw_events: int = 0,
|
|
869
|
+
) -> str | Text:
|
|
870
|
+
"""Generate label for native terminal window."""
|
|
871
|
+
if not self._is_claude_window(w):
|
|
872
|
+
return Text(w.name, no_wrap=True, overflow="ellipsis")
|
|
873
|
+
|
|
874
|
+
title = titles.get(w.claude_session_id or "", "").strip() if w.claude_session_id else ""
|
|
875
|
+
header = title or ("Claude" if w.claude_session_id else "New Claude")
|
|
876
|
+
|
|
877
|
+
details = Text(no_wrap=True, overflow="ellipsis")
|
|
878
|
+
details.append(header)
|
|
879
|
+
details.append("\n")
|
|
880
|
+
|
|
881
|
+
detail_line = "[Claude]"
|
|
882
|
+
if w.claude_session_id:
|
|
883
|
+
detail_line = f"{detail_line} #{self._short_id(w.claude_session_id)}"
|
|
884
|
+
if w.active:
|
|
885
|
+
loaded_count = raw_sessions + raw_events
|
|
886
|
+
detail_line = f"{detail_line} | loaded context: {loaded_count}"
|
|
887
|
+
else:
|
|
888
|
+
detail_line = (
|
|
889
|
+
f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
|
|
890
|
+
)
|
|
891
|
+
details.append(detail_line, style="dim not bold")
|
|
892
|
+
return details
|
|
893
|
+
|
|
894
|
+
def _window_label_tmux(
|
|
574
895
|
self,
|
|
575
896
|
w: tmux_manager.InnerWindow,
|
|
576
897
|
titles: dict[str, str],
|
|
577
898
|
raw_sessions: int = 0,
|
|
578
899
|
raw_events: int = 0,
|
|
579
900
|
) -> str | Text:
|
|
901
|
+
"""Generate label for tmux window."""
|
|
580
902
|
if not self._is_claude_window(w):
|
|
581
903
|
return Text(w.window_name, no_wrap=True, overflow="ellipsis")
|
|
582
904
|
|
|
@@ -587,7 +909,6 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
587
909
|
details.append(header)
|
|
588
910
|
details.append("\n")
|
|
589
911
|
|
|
590
|
-
# Build detail line with Claude label
|
|
591
912
|
detail_line = "[Claude]"
|
|
592
913
|
if w.session_id:
|
|
593
914
|
detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
|
|
@@ -653,6 +974,59 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
653
974
|
self, workspace: str, *, skip_permissions: bool = False
|
|
654
975
|
) -> None:
|
|
655
976
|
"""Create a new Claude terminal."""
|
|
977
|
+
if self._is_native_mode():
|
|
978
|
+
await self._create_claude_terminal_native(workspace, skip_permissions=skip_permissions)
|
|
979
|
+
else:
|
|
980
|
+
await self._create_claude_terminal_tmux(workspace, skip_permissions=skip_permissions)
|
|
981
|
+
|
|
982
|
+
async def _create_claude_terminal_native(
|
|
983
|
+
self, workspace: str, *, skip_permissions: bool = False
|
|
984
|
+
) -> None:
|
|
985
|
+
"""Create a new Claude terminal using native backend."""
|
|
986
|
+
backend = await self._ensure_native_backend()
|
|
987
|
+
if not backend:
|
|
988
|
+
self.app.notify(
|
|
989
|
+
"Native terminal backend not available",
|
|
990
|
+
title="Terminal",
|
|
991
|
+
severity="error",
|
|
992
|
+
)
|
|
993
|
+
return
|
|
994
|
+
|
|
995
|
+
terminal_id = tmux_manager.new_terminal_id()
|
|
996
|
+
context_id = tmux_manager.new_context_id("cc")
|
|
997
|
+
|
|
998
|
+
env = {
|
|
999
|
+
tmux_manager.ENV_TERMINAL_ID: terminal_id,
|
|
1000
|
+
tmux_manager.ENV_TERMINAL_PROVIDER: "claude",
|
|
1001
|
+
tmux_manager.ENV_CONTEXT_ID: context_id,
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
# Install hooks
|
|
1005
|
+
self._install_claude_hooks(workspace)
|
|
1006
|
+
|
|
1007
|
+
claude_cmd = "claude"
|
|
1008
|
+
if skip_permissions:
|
|
1009
|
+
claude_cmd = "claude --dangerously-skip-permissions"
|
|
1010
|
+
|
|
1011
|
+
session_id = await backend.create_tab(
|
|
1012
|
+
command=claude_cmd,
|
|
1013
|
+
terminal_id=terminal_id,
|
|
1014
|
+
name="Claude Code",
|
|
1015
|
+
env=env,
|
|
1016
|
+
cwd=workspace,
|
|
1017
|
+
)
|
|
1018
|
+
|
|
1019
|
+
if not session_id:
|
|
1020
|
+
self.app.notify(
|
|
1021
|
+
"Failed to open Claude terminal",
|
|
1022
|
+
title="Terminal",
|
|
1023
|
+
severity="error",
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
async def _create_claude_terminal_tmux(
|
|
1027
|
+
self, workspace: str, *, skip_permissions: bool = False
|
|
1028
|
+
) -> None:
|
|
1029
|
+
"""Create a new Claude terminal using tmux backend."""
|
|
656
1030
|
terminal_id = tmux_manager.new_terminal_id()
|
|
657
1031
|
context_id = tmux_manager.new_context_id("cc")
|
|
658
1032
|
env = {
|
|
@@ -663,6 +1037,27 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
663
1037
|
tmux_manager.ENV_CONTEXT_ID: context_id,
|
|
664
1038
|
}
|
|
665
1039
|
|
|
1040
|
+
# Install hooks
|
|
1041
|
+
self._install_claude_hooks(workspace)
|
|
1042
|
+
|
|
1043
|
+
claude_cmd = "claude"
|
|
1044
|
+
if skip_permissions:
|
|
1045
|
+
claude_cmd = "claude --dangerously-skip-permissions"
|
|
1046
|
+
command = self._command_in_directory(
|
|
1047
|
+
tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
|
|
1048
|
+
)
|
|
1049
|
+
created = tmux_manager.create_inner_window(
|
|
1050
|
+
"cc",
|
|
1051
|
+
tmux_manager.shell_command_with_env(command, env),
|
|
1052
|
+
terminal_id=terminal_id,
|
|
1053
|
+
provider="claude",
|
|
1054
|
+
context_id=context_id,
|
|
1055
|
+
)
|
|
1056
|
+
if not created:
|
|
1057
|
+
self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
|
|
1058
|
+
|
|
1059
|
+
def _install_claude_hooks(self, workspace: str) -> None:
|
|
1060
|
+
"""Install Claude hooks for a workspace."""
|
|
666
1061
|
try:
|
|
667
1062
|
from ...claude_hooks.stop_hook_installer import (
|
|
668
1063
|
ensure_stop_hook_installed,
|
|
@@ -712,24 +1107,25 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
712
1107
|
except Exception:
|
|
713
1108
|
pass
|
|
714
1109
|
|
|
715
|
-
claude_cmd = "claude"
|
|
716
|
-
if skip_permissions:
|
|
717
|
-
claude_cmd = "claude --dangerously-skip-permissions"
|
|
718
|
-
command = self._command_in_directory(
|
|
719
|
-
tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
|
|
720
|
-
)
|
|
721
|
-
created = tmux_manager.create_inner_window(
|
|
722
|
-
"cc",
|
|
723
|
-
tmux_manager.shell_command_with_env(command, env),
|
|
724
|
-
terminal_id=terminal_id,
|
|
725
|
-
provider="claude",
|
|
726
|
-
context_id=context_id,
|
|
727
|
-
)
|
|
728
|
-
if not created:
|
|
729
|
-
self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
|
|
730
|
-
|
|
731
1110
|
async def _create_codex_terminal(self, workspace: str) -> None:
|
|
732
1111
|
"""Create a new Codex terminal."""
|
|
1112
|
+
if self._is_native_mode():
|
|
1113
|
+
backend = await self._ensure_native_backend()
|
|
1114
|
+
if backend:
|
|
1115
|
+
terminal_id = tmux_manager.new_terminal_id()
|
|
1116
|
+
session_id = await backend.create_tab(
|
|
1117
|
+
command="codex",
|
|
1118
|
+
terminal_id=terminal_id,
|
|
1119
|
+
name="Codex",
|
|
1120
|
+
cwd=workspace,
|
|
1121
|
+
)
|
|
1122
|
+
if not session_id:
|
|
1123
|
+
self.app.notify(
|
|
1124
|
+
"Failed to open Codex terminal", title="Terminal", severity="error"
|
|
1125
|
+
)
|
|
1126
|
+
return
|
|
1127
|
+
|
|
1128
|
+
# Tmux fallback
|
|
733
1129
|
command = self._command_in_directory(
|
|
734
1130
|
tmux_manager.zsh_run_and_keep_open("codex"), workspace
|
|
735
1131
|
)
|
|
@@ -739,6 +1135,23 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
739
1135
|
|
|
740
1136
|
async def _create_opencode_terminal(self, workspace: str) -> None:
|
|
741
1137
|
"""Create a new Opencode terminal."""
|
|
1138
|
+
if self._is_native_mode():
|
|
1139
|
+
backend = await self._ensure_native_backend()
|
|
1140
|
+
if backend:
|
|
1141
|
+
terminal_id = tmux_manager.new_terminal_id()
|
|
1142
|
+
session_id = await backend.create_tab(
|
|
1143
|
+
command="opencode",
|
|
1144
|
+
terminal_id=terminal_id,
|
|
1145
|
+
name="Opencode",
|
|
1146
|
+
cwd=workspace,
|
|
1147
|
+
)
|
|
1148
|
+
if not session_id:
|
|
1149
|
+
self.app.notify(
|
|
1150
|
+
"Failed to open Opencode terminal", title="Terminal", severity="error"
|
|
1151
|
+
)
|
|
1152
|
+
return
|
|
1153
|
+
|
|
1154
|
+
# Tmux fallback
|
|
742
1155
|
command = self._command_in_directory(
|
|
743
1156
|
tmux_manager.zsh_run_and_keep_open("opencode"), workspace
|
|
744
1157
|
)
|
|
@@ -748,6 +1161,23 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
748
1161
|
|
|
749
1162
|
async def _create_zsh_terminal(self, workspace: str) -> None:
|
|
750
1163
|
"""Create a new zsh terminal."""
|
|
1164
|
+
if self._is_native_mode():
|
|
1165
|
+
backend = await self._ensure_native_backend()
|
|
1166
|
+
if backend:
|
|
1167
|
+
terminal_id = tmux_manager.new_terminal_id()
|
|
1168
|
+
session_id = await backend.create_tab(
|
|
1169
|
+
command="zsh -l",
|
|
1170
|
+
terminal_id=terminal_id,
|
|
1171
|
+
name="zsh",
|
|
1172
|
+
cwd=workspace,
|
|
1173
|
+
)
|
|
1174
|
+
if not session_id:
|
|
1175
|
+
self.app.notify(
|
|
1176
|
+
"Failed to open zsh terminal", title="Terminal", severity="error"
|
|
1177
|
+
)
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
# Tmux fallback
|
|
751
1181
|
command = self._command_in_directory("zsh", workspace)
|
|
752
1182
|
created = tmux_manager.create_inner_window("zsh", command)
|
|
753
1183
|
if not created:
|
|
@@ -771,14 +1201,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
771
1201
|
return
|
|
772
1202
|
|
|
773
1203
|
if button_id.startswith("switch-"):
|
|
774
|
-
|
|
775
|
-
if not window_id or not tmux_manager.select_inner_window(window_id):
|
|
776
|
-
self.app.notify("Failed to switch terminal", title="Terminal", severity="error")
|
|
777
|
-
# Clear attention when user clicks on terminal
|
|
778
|
-
if window_id:
|
|
779
|
-
tmux_manager.clear_attention(window_id)
|
|
780
|
-
self._expanded_window_id = None
|
|
781
|
-
await self.refresh_data()
|
|
1204
|
+
await self._handle_switch(event.button.name or "")
|
|
782
1205
|
return
|
|
783
1206
|
|
|
784
1207
|
if button_id.startswith("toggle-"):
|
|
@@ -805,7 +1228,46 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
805
1228
|
return
|
|
806
1229
|
|
|
807
1230
|
if button_id.startswith("close-"):
|
|
808
|
-
|
|
809
|
-
|
|
1231
|
+
await self._handle_close(event.button.name or "")
|
|
1232
|
+
return
|
|
1233
|
+
|
|
1234
|
+
async def _handle_switch(self, window_id: str) -> None:
|
|
1235
|
+
"""Handle switching to a terminal."""
|
|
1236
|
+
if not window_id:
|
|
1237
|
+
return
|
|
1238
|
+
|
|
1239
|
+
if self._is_native_mode():
|
|
1240
|
+
backend = await self._ensure_native_backend()
|
|
1241
|
+
if backend:
|
|
1242
|
+
success = await backend.focus_tab(window_id, steal_focus=False)
|
|
1243
|
+
if not success:
|
|
1244
|
+
self.app.notify(
|
|
1245
|
+
"Failed to switch terminal", title="Terminal", severity="error"
|
|
1246
|
+
)
|
|
1247
|
+
else:
|
|
1248
|
+
if not tmux_manager.select_inner_window(window_id):
|
|
1249
|
+
self.app.notify("Failed to switch terminal", title="Terminal", severity="error")
|
|
1250
|
+
# Clear attention when user clicks on terminal
|
|
1251
|
+
tmux_manager.clear_attention(window_id)
|
|
1252
|
+
|
|
1253
|
+
self._expanded_window_id = None
|
|
1254
|
+
await self.refresh_data()
|
|
1255
|
+
|
|
1256
|
+
async def _handle_close(self, window_id: str) -> None:
|
|
1257
|
+
"""Handle closing a terminal."""
|
|
1258
|
+
if not window_id:
|
|
1259
|
+
return
|
|
1260
|
+
|
|
1261
|
+
if self._is_native_mode():
|
|
1262
|
+
backend = await self._ensure_native_backend()
|
|
1263
|
+
if backend:
|
|
1264
|
+
success = await backend.close_tab(window_id)
|
|
1265
|
+
if not success:
|
|
1266
|
+
self.app.notify(
|
|
1267
|
+
"Failed to close terminal", title="Terminal", severity="error"
|
|
1268
|
+
)
|
|
1269
|
+
else:
|
|
1270
|
+
if not tmux_manager.kill_inner_window(window_id):
|
|
810
1271
|
self.app.notify("Failed to close terminal", title="Terminal", severity="error")
|
|
811
|
-
|
|
1272
|
+
|
|
1273
|
+
await self.refresh_data()
|