aline-ai 0.6.6__py3-none-any.whl → 0.6.7__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.
@@ -1,1688 +0,0 @@
1
- """Terminal controls panel with native terminal and tmux support.
2
-
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)
10
- """
11
-
12
- from __future__ import annotations
13
-
14
- import asyncio
15
- import os
16
- import re
17
- import shlex
18
- import time
19
- import traceback
20
- from datetime import datetime, timedelta, timezone
21
- from pathlib import Path
22
- from typing import Callable, Union
23
-
24
- from textual.app import ComposeResult
25
- from textual.containers import Container, Horizontal, Vertical, VerticalScroll
26
- from textual.message import Message
27
- from textual.widgets import Button, Static
28
- from rich.text import Text
29
-
30
- from .. import tmux_manager
31
- from ..terminal_backend import TerminalBackend, TerminalInfo
32
- from ...logging_config import setup_logger
33
-
34
- logger = setup_logger("realign.dashboard.terminal", "dashboard.log")
35
-
36
-
37
- # Signal directory for permission request notifications
38
- PERMISSION_SIGNAL_DIR = Path.home() / ".aline" / ".signals" / "permission_request"
39
-
40
- # Environment variable to control terminal mode
41
- ENV_TERMINAL_MODE = "ALINE_TERMINAL_MODE"
42
-
43
- # Terminal mode constants
44
- MODE_TMUX = "tmux"
45
- MODE_NATIVE = "native"
46
- MODE_ITERM2 = "iterm2"
47
- MODE_KITTY = "kitty"
48
-
49
-
50
- # Type for window data (either tmux InnerWindow or native TerminalInfo)
51
- WindowData = Union[tmux_manager.InnerWindow, TerminalInfo]
52
-
53
-
54
- class _SignalFileWatcher:
55
- """Watches for new signal files in the permission_request directory.
56
-
57
- Uses OS-native file watching via asyncio when available,
58
- otherwise falls back to checking directory mtime.
59
- """
60
-
61
- def __init__(self, callback: Callable[[], None]) -> None:
62
- self._callback = callback
63
- self._running = False
64
- self._task: asyncio.Task | None = None
65
- self._last_mtime: float = 0
66
- self._seen_files: set[str] = set()
67
-
68
- def start(self) -> None:
69
- if self._running:
70
- return
71
- self._running = True
72
- # Initialize seen files
73
- self._scan_existing_files()
74
- self._task = asyncio.create_task(self._watch_loop())
75
-
76
- def stop(self) -> None:
77
- self._running = False
78
- if self._task:
79
- self._task.cancel()
80
- self._task = None
81
-
82
- def _scan_existing_files(self) -> None:
83
- """Record existing signal files so we only react to new ones."""
84
- try:
85
- if PERMISSION_SIGNAL_DIR.exists():
86
- self._seen_files = {
87
- f.name for f in PERMISSION_SIGNAL_DIR.iterdir() if f.suffix == ".signal"
88
- }
89
- except Exception:
90
- self._seen_files = set()
91
-
92
- async def _watch_loop(self) -> None:
93
- """Watch for new signal files using directory mtime checks."""
94
- try:
95
- while self._running:
96
- # Wait a bit before checking (reduces CPU usage)
97
- await asyncio.sleep(0.5)
98
-
99
- if not self._running:
100
- break
101
-
102
- try:
103
- if not PERMISSION_SIGNAL_DIR.exists():
104
- continue
105
-
106
- # Check if directory was modified
107
- current_mtime = PERMISSION_SIGNAL_DIR.stat().st_mtime
108
- if current_mtime <= self._last_mtime:
109
- continue
110
- self._last_mtime = current_mtime
111
-
112
- # Check for new signal files
113
- current_files = {
114
- f.name for f in PERMISSION_SIGNAL_DIR.iterdir() if f.suffix == ".signal"
115
- }
116
- new_files = current_files - self._seen_files
117
-
118
- if new_files:
119
- self._seen_files = current_files
120
- # New signal file detected - trigger callback
121
- self._callback()
122
- # Clean up old signal files (keep last 10)
123
- self._cleanup_old_signals()
124
-
125
- except Exception:
126
- pass # Ignore errors, keep watching
127
-
128
- except asyncio.CancelledError:
129
- pass
130
-
131
- def _cleanup_old_signals(self) -> None:
132
- """Remove old signal files to prevent directory from growing."""
133
- try:
134
- if not PERMISSION_SIGNAL_DIR.exists():
135
- return
136
- files = sorted(
137
- PERMISSION_SIGNAL_DIR.glob("*.signal"),
138
- key=lambda f: f.stat().st_mtime,
139
- reverse=True,
140
- )
141
- # Keep only the 10 most recent
142
- for f in files[10:]:
143
- try:
144
- f.unlink()
145
- except Exception:
146
- pass
147
- except Exception:
148
- pass
149
-
150
-
151
- def _detect_terminal_mode() -> str:
152
- """Detect which terminal mode to use.
153
-
154
- Returns:
155
- Terminal mode string (MODE_TMUX, MODE_ITERM2, or MODE_KITTY)
156
- """
157
- mode = os.environ.get(ENV_TERMINAL_MODE, "").strip().lower()
158
-
159
- if mode in {MODE_ITERM2, "iterm"}:
160
- return MODE_ITERM2
161
- if mode == MODE_KITTY:
162
- return MODE_KITTY
163
- if mode == MODE_NATIVE:
164
- # Auto-detect best native terminal
165
- term_program = os.environ.get("TERM_PROGRAM", "").strip()
166
- if term_program in {"iTerm.app", "iTerm2"} or term_program.startswith("iTerm"):
167
- return MODE_ITERM2
168
- if term_program == "kitty":
169
- return MODE_KITTY
170
- # Default to iTerm2 on macOS
171
- import sys
172
- if sys.platform == "darwin":
173
- return MODE_ITERM2
174
- return MODE_TMUX
175
-
176
- # Default to tmux
177
- return MODE_TMUX
178
-
179
-
180
- async def _get_native_backend(mode: str) -> TerminalBackend | None:
181
- """Get the appropriate native terminal backend.
182
-
183
- Args:
184
- mode: Terminal mode (MODE_ITERM2 or MODE_KITTY)
185
-
186
- Returns:
187
- Backend instance if available, None otherwise
188
- """
189
- if mode == MODE_ITERM2:
190
- try:
191
- from ..backends.iterm2 import ITermBackend
192
-
193
- # Check for split pane session ID from environment
194
- right_pane_session_id = os.environ.get("ALINE_ITERM2_RIGHT_PANE")
195
- if right_pane_session_id:
196
- logger.debug(f"Using split pane mode with right pane: {right_pane_session_id}")
197
-
198
- backend = ITermBackend(right_pane_session_id=right_pane_session_id)
199
- if await backend.is_available():
200
- return backend
201
- except Exception as e:
202
- logger.debug(f"iTerm2 backend not available: {e}")
203
- elif mode == MODE_KITTY:
204
- try:
205
- from ..backends.kitty import KittyBackend
206
- backend = KittyBackend()
207
- if await backend.is_available():
208
- return backend
209
- except Exception as e:
210
- logger.debug(f"Kitty backend not available: {e}")
211
-
212
- return None
213
-
214
-
215
- class TerminalPanel(Container, can_focus=True):
216
- """Terminal controls panel with permission request notifications.
217
-
218
- Supports both native terminal backends (iTerm2/Kitty) and tmux.
219
- """
220
-
221
- class PermissionRequestDetected(Message):
222
- """Posted when a new permission request signal file is detected."""
223
-
224
- pass
225
-
226
- DEFAULT_CSS = """
227
- TerminalPanel {
228
- height: 100%;
229
- padding: 0 1;
230
- overflow: hidden;
231
- }
232
-
233
- TerminalPanel:focus {
234
- border: none;
235
- }
236
-
237
- TerminalPanel .summary {
238
- height: auto;
239
- margin: 0 0 1 0;
240
- padding: 0;
241
- background: transparent;
242
- border: none;
243
- }
244
-
245
- /* Override global dashboard button borders for a compact "list" look. */
246
- TerminalPanel Button {
247
- min-width: 0;
248
- padding: 0 1;
249
- background: transparent;
250
- border: none;
251
- }
252
-
253
- TerminalPanel Button:hover {
254
- background: $surface-lighten-1;
255
- }
256
-
257
- TerminalPanel .summary Button {
258
- width: auto;
259
- margin-right: 1;
260
- }
261
-
262
- TerminalPanel .status {
263
- width: 1fr;
264
- height: auto;
265
- color: $text-muted;
266
- content-align: right middle;
267
- }
268
-
269
- TerminalPanel .list {
270
- height: 1fr;
271
- padding: 0;
272
- overflow-y: auto;
273
- border: none;
274
- background: transparent;
275
- }
276
-
277
- TerminalPanel .terminal-row {
278
- height: auto;
279
- min-height: 2;
280
- margin: 0 0 1 0;
281
- }
282
-
283
- TerminalPanel .terminal-row Button.terminal-switch {
284
- width: 1fr;
285
- height: 2;
286
- margin: 0;
287
- padding: 0 1;
288
- text-align: left;
289
- content-align: left top;
290
- }
291
-
292
- TerminalPanel .terminal-row Button.terminal-close {
293
- width: 3;
294
- min-width: 3;
295
- height: 2;
296
- margin-left: 1;
297
- padding: 0;
298
- content-align: center middle;
299
- }
300
-
301
- TerminalPanel .terminal-row .attention-dot {
302
- width: 2;
303
- min-width: 2;
304
- height: 2;
305
- color: $error;
306
- content-align: center middle;
307
- margin-right: 0;
308
- }
309
-
310
- TerminalPanel .terminal-row Button.terminal-toggle {
311
- width: 3;
312
- min-width: 3;
313
- height: 2;
314
- margin-left: 1;
315
- padding: 0;
316
- content-align: center middle;
317
- }
318
-
319
- TerminalPanel .context-sessions {
320
- height: 8;
321
- margin: -1 0 1 2;
322
- color: $text-muted;
323
- padding: 0;
324
- border: none;
325
- overflow-y: auto;
326
- }
327
-
328
- TerminalPanel Button.context-session {
329
- width: 1fr;
330
- height: auto;
331
- margin: 0 0 0 0;
332
- padding: 0 0;
333
- background: transparent;
334
- border: none;
335
- text-style: none;
336
- text-align: left;
337
- content-align: left middle;
338
- }
339
-
340
- TerminalPanel .context-sessions Static {
341
- text-align: left;
342
- content-align: left middle;
343
- }
344
- """
345
-
346
- @staticmethod
347
- def supported() -> bool:
348
- """Check if terminal controls are supported.
349
-
350
- Supports both native terminal mode and tmux mode.
351
- """
352
- mode = _detect_terminal_mode()
353
-
354
- if mode == MODE_TMUX:
355
- return (
356
- tmux_manager.tmux_available()
357
- and tmux_manager.in_tmux()
358
- and tmux_manager.managed_env_enabled()
359
- )
360
-
361
- # For native mode, we check availability asynchronously in refresh_data
362
- # Here we just return True if native mode is requested
363
- return mode in {MODE_ITERM2, MODE_KITTY}
364
-
365
- @staticmethod
366
- def _support_message() -> str:
367
- mode = _detect_terminal_mode()
368
-
369
- if mode == MODE_TMUX:
370
- if not tmux_manager.tmux_available():
371
- return "tmux not installed. Run `aline add tmux`, then restart `aline`."
372
- if not tmux_manager.in_tmux():
373
- return "Not running inside tmux. Restart with `aline` to enable terminal controls."
374
- if not tmux_manager.managed_env_enabled():
375
- return "Not in an Aline-managed tmux session. Start via `aline` to enable terminal controls."
376
- elif mode == MODE_ITERM2:
377
- return "iTerm2 Python API not available. Install with: pip install iterm2"
378
- elif mode == MODE_KITTY:
379
- return "Kitty remote control not available. Configure listen_on in kitty.conf"
380
-
381
- return ""
382
-
383
- @staticmethod
384
- def _is_claude_window(w: WindowData) -> bool:
385
- """Check if a window is a Claude terminal."""
386
- if isinstance(w, TerminalInfo):
387
- # Native terminal: check provider
388
- return w.provider == "claude"
389
-
390
- window_name = (w.window_name or "").strip().lower()
391
- if re.fullmatch(r"codex(?:-\d+)?", window_name or ""):
392
- return False
393
-
394
- # tmux: prefer explicit provider/session_type tags
395
- if (w.provider or w.session_type):
396
- return (w.provider == "claude") or (w.session_type == "claude")
397
-
398
- # tmux: fallback to window name heuristic
399
- if re.fullmatch(r"cc(?:-\d+)?", window_name or ""):
400
- return True
401
- return False
402
-
403
- @staticmethod
404
- def _is_codex_window(w: WindowData) -> bool:
405
- """Check if a window is a Codex terminal."""
406
- if isinstance(w, TerminalInfo):
407
- return w.provider == "codex"
408
-
409
- # tmux: prefer explicit provider/session_type tags
410
- if (w.provider or w.session_type):
411
- return (w.provider == "codex") or (w.session_type == "codex")
412
-
413
- window_name = (w.window_name or "").strip().lower()
414
- if re.fullmatch(r"codex(?:-\d+)?", window_name or ""):
415
- return True
416
-
417
- return False
418
-
419
- @classmethod
420
- def _supports_context(cls, w: WindowData) -> bool:
421
- return cls._is_claude_window(w) or cls._is_codex_window(w)
422
-
423
- @staticmethod
424
- def _is_internal_tmux_window(w: tmux_manager.InnerWindow) -> bool:
425
- """Hide internal tmux windows (e.g., the reserved 'home' window)."""
426
- if not w.no_track:
427
- return False
428
- if (w.window_name or "").strip().lower() != "home":
429
- return False
430
- return not any(
431
- (
432
- (w.terminal_id or "").strip(),
433
- (w.provider or "").strip(),
434
- (w.session_type or "").strip(),
435
- (w.session_id or "").strip(),
436
- (w.context_id or "").strip(),
437
- (w.transcript_path or "").strip(),
438
- (w.attention or "").strip(),
439
- )
440
- )
441
-
442
- def _maybe_link_codex_session_for_terminal(
443
- self, *, terminal_id: str, created_at: float | None
444
- ) -> None:
445
- """Best-effort: bind a Codex session file to a dashboard terminal (no watcher required)."""
446
- terminal_id = (terminal_id or "").strip()
447
- if not terminal_id:
448
- return
449
-
450
- now = time.time()
451
- last = self._codex_link_last_attempt.get(terminal_id, 0.0)
452
- if now - last < 2.0:
453
- return
454
- self._codex_link_last_attempt[terminal_id] = now
455
-
456
- try:
457
- from ...codex_terminal_linker import read_codex_session_meta
458
- from ...db import get_database
459
- from ...codex_home import codex_sessions_dir_for_terminal_or_agent
460
- except Exception:
461
- return
462
-
463
- try:
464
- db = get_database(read_only=False)
465
- agent = db.get_agent_by_id(terminal_id)
466
- if not agent or agent.provider != "codex" or agent.status != "active":
467
- return
468
- if agent.session_id:
469
- return
470
- cwd = (agent.cwd or "").strip()
471
- if not cwd:
472
- return
473
- except Exception:
474
- return
475
-
476
- candidates: list[Path] = []
477
- agent_info_id: str | None = None
478
- if (agent.source or "").startswith("agent:"):
479
- agent_info_id = agent.source[6:]
480
- sessions_root = codex_sessions_dir_for_terminal_or_agent(terminal_id, agent_info_id)
481
- if sessions_root.exists():
482
- # Deterministic: isolated per-terminal/per-agent CODEX_HOME.
483
- try:
484
- candidates = list(sessions_root.rglob("rollout-*.jsonl"))
485
- except Exception:
486
- candidates = []
487
- else:
488
- # Fallback for legacy terminals not launched with isolated CODEX_HOME.
489
- try:
490
- from ...codex_detector import find_codex_sessions_for_project
491
-
492
- candidates = find_codex_sessions_for_project(Path(cwd), days_back=3)
493
- except Exception:
494
- candidates = []
495
- if not candidates:
496
- return
497
-
498
- created_dt: datetime | None = None
499
- if created_at is not None:
500
- try:
501
- created_dt = datetime.fromtimestamp(float(created_at), tz=timezone.utc)
502
- except Exception:
503
- created_dt = None
504
-
505
- best: Path | None = None
506
- best_score: float | None = None
507
- candidates.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
508
- for session_file in candidates[:200]:
509
- meta = read_codex_session_meta(session_file)
510
- if meta is None or (meta.cwd or "").strip() != cwd:
511
- continue
512
-
513
- started_dt: datetime | None = meta.started_at
514
- if started_dt is None:
515
- try:
516
- started_dt = datetime.fromtimestamp(
517
- session_file.stat().st_mtime, tz=timezone.utc
518
- )
519
- except Exception:
520
- started_dt = None
521
- if started_dt is None:
522
- continue
523
-
524
- if created_dt is not None:
525
- delta = abs((started_dt - created_dt).total_seconds())
526
- else:
527
- try:
528
- delta = abs(time.time() - session_file.stat().st_mtime)
529
- except Exception:
530
- continue
531
-
532
- penalty = 0.0
533
- origin = (meta.originator or "").lower()
534
- if "vscode" in origin:
535
- penalty += 3600.0
536
- score = float(delta) + penalty
537
-
538
- if best_score is None or score < best_score:
539
- best_score = score
540
- best = session_file
541
-
542
- if not best:
543
- return
544
-
545
- # Avoid binding wildly unrelated sessions.
546
- if best_score is not None and best_score > 6 * 60 * 60:
547
- return
548
-
549
- try:
550
- source = "dashboard:auto-link"
551
- if (agent.source or "").startswith("agent:"):
552
- source = agent.source or source
553
- db.update_agent(
554
- terminal_id,
555
- provider="codex",
556
- session_type="codex",
557
- session_id=best.stem,
558
- transcript_path=str(best),
559
- cwd=cwd,
560
- project_dir=cwd,
561
- source=source,
562
- )
563
- if agent_info_id:
564
- try:
565
- db.update_session_agent_id(best.stem, agent_info_id)
566
- except Exception:
567
- pass
568
- except Exception:
569
- return
570
-
571
- def __init__(self, use_native_terminal: bool | None = None) -> None:
572
- """Initialize the terminal panel.
573
-
574
- Args:
575
- use_native_terminal: If True, use native terminal backend.
576
- If False, use tmux.
577
- If None (default), auto-detect from environment.
578
- """
579
- super().__init__()
580
- self._refresh_lock = asyncio.Lock()
581
- self._expanded_window_id: str | None = None
582
- self._signal_watcher: _SignalFileWatcher | None = None
583
-
584
- # Determine terminal mode
585
- if use_native_terminal is True:
586
- self._mode = _detect_terminal_mode()
587
- if self._mode == MODE_TMUX:
588
- self._mode = MODE_ITERM2 # Default to iTerm2 if native requested
589
- elif use_native_terminal is False:
590
- self._mode = MODE_TMUX
591
- else:
592
- self._mode = _detect_terminal_mode()
593
-
594
- # Native backend (initialized lazily)
595
- self._native_backend: TerminalBackend | None = None
596
- self._native_backend_checked = False
597
-
598
- # Best-effort Codex session binding without requiring the watcher process.
599
- self._codex_link_last_attempt: dict[str, float] = {}
600
-
601
- def compose(self) -> ComposeResult:
602
- logger.debug("TerminalPanel.compose() started")
603
- try:
604
- controls_enabled = self.supported()
605
- with Horizontal(classes="summary"):
606
- yield Button(
607
- "+ New Agent",
608
- id="quick-new-agent",
609
- variant="primary",
610
- disabled=not controls_enabled,
611
- )
612
- yield Button(
613
- "+ Create",
614
- id="new-agent",
615
- variant="default",
616
- disabled=not controls_enabled,
617
- )
618
- with Vertical(id="terminals", classes="list"):
619
- if controls_enabled:
620
- yield Static(
621
- "No terminals yet. Click 'Create' to open a new agent terminal."
622
- )
623
- else:
624
- yield Static(self._support_message())
625
- logger.debug("TerminalPanel.compose() completed")
626
- except Exception as e:
627
- logger.error(f"TerminalPanel.compose() failed: {e}\n{traceback.format_exc()}")
628
- raise
629
-
630
- def on_show(self) -> None:
631
- self.call_after_refresh(
632
- lambda: self.run_worker(
633
- self.refresh_data(),
634
- group="terminal-panel-refresh",
635
- exclusive=True,
636
- )
637
- )
638
- self._start_signal_watcher()
639
-
640
- def on_hide(self) -> None:
641
- self._stop_signal_watcher()
642
-
643
- def _start_signal_watcher(self) -> None:
644
- """Start watching for permission request signal files."""
645
- if self._signal_watcher is not None:
646
- return
647
- self._signal_watcher = _SignalFileWatcher(self._on_permission_signal)
648
- self._signal_watcher.start()
649
-
650
- def _stop_signal_watcher(self) -> None:
651
- """Stop watching for permission request signal files."""
652
- if self._signal_watcher is not None:
653
- self._signal_watcher.stop()
654
- self._signal_watcher = None
655
-
656
- def _on_permission_signal(self) -> None:
657
- """Called when a new permission request signal is detected."""
658
- self.post_message(self.PermissionRequestDetected())
659
-
660
- def on_terminal_panel_permission_request_detected(
661
- self, event: PermissionRequestDetected
662
- ) -> None:
663
- """Handle permission request detection - refresh the terminal list."""
664
- self.run_worker(
665
- self.refresh_data(),
666
- group="terminal-panel-refresh",
667
- exclusive=True,
668
- )
669
-
670
- async def _ensure_native_backend(self) -> TerminalBackend | None:
671
- """Ensure native backend is initialized."""
672
- if self._native_backend_checked:
673
- return self._native_backend
674
-
675
- self._native_backend_checked = True
676
-
677
- if self._mode in {MODE_ITERM2, MODE_KITTY}:
678
- self._native_backend = await _get_native_backend(self._mode)
679
-
680
- return self._native_backend
681
-
682
- def _is_native_mode(self) -> bool:
683
- """Check if we're using native terminal mode."""
684
- return self._mode in {MODE_ITERM2, MODE_KITTY}
685
-
686
- async def refresh_data(self) -> None:
687
- async with self._refresh_lock:
688
- t_start = time.time()
689
- # Check and close stale terminals if enabled
690
- await self._close_stale_terminals_if_enabled()
691
- logger.debug(f"[PERF] _close_stale_terminals_if_enabled: {time.time() - t_start:.3f}s")
692
-
693
- t_refresh = time.time()
694
- if self._is_native_mode():
695
- await self._refresh_native_data()
696
- else:
697
- await self._refresh_tmux_data()
698
- logger.debug(f"[PERF] total refresh: {time.time() - t_start:.3f}s")
699
-
700
- async def _close_stale_terminals_if_enabled(self) -> None:
701
- """Close terminals that haven't been updated for the configured hours."""
702
- try:
703
- from ...config import ReAlignConfig
704
-
705
- config = ReAlignConfig.load()
706
- if not config.auto_close_stale_terminals:
707
- return
708
-
709
- stale_hours = config.stale_terminal_hours or 24
710
- cutoff_time = datetime.now() - timedelta(hours=stale_hours)
711
-
712
- # Get stale agents from database
713
- from ...db import get_database
714
-
715
- db = get_database(read_only=True)
716
- all_agents = db.list_agents(status="active", limit=1000)
717
-
718
- stale_agent_ids = set()
719
- for agent in all_agents:
720
- if agent.updated_at and agent.updated_at < cutoff_time:
721
- stale_agent_ids.add(agent.id)
722
-
723
- if not stale_agent_ids:
724
- return
725
-
726
- # Get current windows
727
- if self._is_native_mode():
728
- backend = await self._ensure_native_backend()
729
- if not backend:
730
- return
731
- windows = await backend.list_tabs()
732
- for w in windows:
733
- if w.terminal_id in stale_agent_ids:
734
- logger.info(f"Auto-closing stale terminal: {w.terminal_id}")
735
- await backend.close_tab(w.session_id)
736
- else:
737
- windows = tmux_manager.list_inner_windows()
738
- for w in windows:
739
- if w.terminal_id in stale_agent_ids:
740
- logger.info(f"Auto-closing stale terminal: {w.terminal_id}")
741
- tmux_manager.kill_inner_window(w.window_id)
742
-
743
- except Exception as e:
744
- logger.debug(f"Error checking stale terminals: {e}")
745
-
746
- async def _refresh_native_data(self) -> None:
747
- """Refresh data using native terminal backend."""
748
- backend = await self._ensure_native_backend()
749
- if not backend:
750
- # Fall back to showing error message
751
- try:
752
- container = self.query_one("#terminals", Vertical)
753
- await container.remove_children()
754
- await container.mount(Static(self._support_message()))
755
- except Exception:
756
- pass
757
- return
758
-
759
- try:
760
- windows = await backend.list_tabs()
761
- except Exception as e:
762
- logger.error(f"Failed to list native terminals: {e}")
763
- return
764
-
765
- # Yield to event loop to keep UI responsive
766
- await asyncio.sleep(0)
767
-
768
- # NOTE: _maybe_link_codex_session_for_terminal is intentionally skipped here
769
- # because it performs expensive file system scans (find_codex_sessions_for_project)
770
- # that can take minutes with many session files. Codex session linking is handled
771
- # by the watcher process instead.
772
-
773
- active_window_id = next(
774
- (w.session_id for w in windows if w.active), None
775
- )
776
- if self._expanded_window_id and self._expanded_window_id != active_window_id:
777
- self._expanded_window_id = None
778
-
779
- # Titles (best-effort; native terminals only expose Claude session ids today)
780
- claude_ids = [
781
- w.claude_session_id
782
- for w in windows
783
- if self._is_claude_window(w) and w.claude_session_id
784
- ]
785
- titles = self._fetch_claude_session_titles(claude_ids)
786
-
787
- # Yield to event loop after DB query
788
- await asyncio.sleep(0)
789
-
790
- # Get context info
791
- context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
792
- all_context_session_ids: set[str] = set()
793
- for w in windows:
794
- if not self._supports_context(w) or not w.context_id:
795
- continue
796
- session_ids, session_count, event_count = self._get_loaded_context_info(
797
- w.context_id
798
- )
799
- if not session_ids and session_count == 0 and event_count == 0:
800
- continue
801
- context_info_by_context_id[w.context_id] = (
802
- session_ids,
803
- session_count,
804
- event_count,
805
- )
806
- all_context_session_ids.update(session_ids)
807
-
808
- if all_context_session_ids:
809
- titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
810
-
811
- try:
812
- await self._render_terminals_native(windows, titles, context_info_by_context_id)
813
- except Exception:
814
- return
815
-
816
- async def _refresh_tmux_data(self) -> None:
817
- """Refresh data using tmux backend."""
818
- t0 = time.time()
819
- try:
820
- supported = self.supported()
821
- except Exception:
822
- return
823
-
824
- if not supported:
825
- return
826
-
827
- try:
828
- windows = tmux_manager.list_inner_windows()
829
- except Exception:
830
- return
831
- windows = [w for w in windows if not self._is_internal_tmux_window(w)]
832
- logger.debug(f"[PERF] list_inner_windows: {time.time() - t0:.3f}s")
833
-
834
- # Yield to event loop to keep UI responsive
835
- await asyncio.sleep(0)
836
-
837
- # NOTE: _maybe_link_codex_session_for_terminal is intentionally skipped here
838
- # because it performs expensive file system scans (find_codex_sessions_for_project)
839
- # that can take minutes with many session files. Codex session linking is handled
840
- # by the watcher process instead.
841
-
842
- active_window_id = next((w.window_id for w in windows if w.active), None)
843
- if self._expanded_window_id and self._expanded_window_id != active_window_id:
844
- self._expanded_window_id = None
845
-
846
- t1 = time.time()
847
- session_ids = [w.session_id for w in windows if self._supports_context(w) and w.session_id]
848
- titles = self._fetch_claude_session_titles(session_ids)
849
- logger.debug(f"[PERF] fetch_claude_session_titles: {time.time() - t1:.3f}s")
850
-
851
- # Yield to event loop after DB query
852
- await asyncio.sleep(0)
853
-
854
- t2 = time.time()
855
- context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
856
- all_context_session_ids: set[str] = set()
857
- for w in windows:
858
- if not self._supports_context(w) or not w.context_id:
859
- continue
860
- session_ids, session_count, event_count = self._get_loaded_context_info(
861
- w.context_id
862
- )
863
- if not session_ids and session_count == 0 and event_count == 0:
864
- continue
865
- context_info_by_context_id[w.context_id] = (
866
- session_ids,
867
- session_count,
868
- event_count,
869
- )
870
- all_context_session_ids.update(session_ids)
871
- # Yield periodically during context info gathering
872
- await asyncio.sleep(0)
873
- logger.debug(f"[PERF] get_loaded_context_info loop: {time.time() - t2:.3f}s")
874
-
875
- t3 = time.time()
876
- if all_context_session_ids:
877
- titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
878
- logger.debug(f"[PERF] fetch context session titles: {time.time() - t3:.3f}s")
879
-
880
- t4 = time.time()
881
- try:
882
- await self._render_terminals_tmux(windows, titles, context_info_by_context_id)
883
- except Exception:
884
- return
885
- logger.debug(f"[PERF] render_terminals_tmux: {time.time() - t4:.3f}s")
886
-
887
- def _fetch_claude_session_titles(self, session_ids: list[str]) -> dict[str, str]:
888
- # Back-compat hook for tests and older call sites.
889
- return self._fetch_session_titles(session_ids)
890
-
891
- def _fetch_session_titles(self, session_ids: list[str]) -> dict[str, str]:
892
- if not session_ids:
893
- return {}
894
- try:
895
- from ...db import get_database
896
-
897
- db = get_database(read_only=True)
898
- sessions = db.get_sessions_by_ids(session_ids)
899
- titles: dict[str, str] = {}
900
- for s in sessions:
901
- title = (s.session_title or "").strip()
902
- if title:
903
- titles[s.id] = title
904
- return titles
905
- except Exception:
906
- return {}
907
-
908
- def _get_loaded_context_info(self, context_id: str) -> tuple[list[str], int, int]:
909
- """Best-effort: read ~/.aline/load.json for a context_id, and return its session ids."""
910
- context_id = (context_id or "").strip()
911
- if not context_id:
912
- return ([], 0, 0)
913
- try:
914
- from ...context import get_context_by_id, load_context_config
915
-
916
- config = load_context_config()
917
- if config is None:
918
- return ([], 0, 0)
919
- entry = get_context_by_id(context_id, config)
920
- if entry is None:
921
- return ([], 0, 0)
922
-
923
- raw_sessions: set[str] = set(
924
- str(s).strip() for s in (entry.context_sessions or []) if str(s).strip()
925
- )
926
- raw_events: list[str] = [
927
- str(e).strip() for e in (entry.context_events or []) if str(e).strip()
928
- ]
929
- raw_event_ids: set[str] = set(raw_events)
930
-
931
- out: set[str] = set(raw_sessions)
932
-
933
- if raw_events:
934
- try:
935
- from ...db import get_database
936
-
937
- db = get_database(read_only=True)
938
- for event_id in raw_events:
939
- try:
940
- sessions = db.get_sessions_for_event(str(event_id))
941
- out.update(s.id for s in sessions if getattr(s, "id", None))
942
- except Exception:
943
- continue
944
- except Exception:
945
- pass
946
-
947
- return (sorted(out), len(raw_sessions), len(raw_event_ids))
948
- except Exception:
949
- return ([], 0, 0)
950
-
951
- async def _render_terminals_native(
952
- self,
953
- windows: list[TerminalInfo],
954
- titles: dict[str, str],
955
- context_info_by_context_id: dict[str, tuple[list[str], int, int]],
956
- ) -> None:
957
- """Render terminal list for native backend."""
958
- container = self.query_one("#terminals", Vertical)
959
- await container.remove_children()
960
-
961
- if not windows:
962
- await container.mount(
963
- Static("No terminals yet. Click 'Create' to open a new agent terminal.")
964
- )
965
- return
966
-
967
- for w in windows:
968
- safe = self._safe_id_fragment(w.session_id)
969
- row = Horizontal(classes="terminal-row")
970
- await container.mount(row)
971
-
972
- if w.attention:
973
- await row.mount(Static("●", classes="attention-dot"))
974
-
975
- switch_classes = "terminal-switch active" if w.active else "terminal-switch"
976
- loaded_ids: list[str] = []
977
- raw_sessions = 0
978
- raw_events = 0
979
- if self._supports_context(w) and w.context_id:
980
- loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
981
- w.context_id, ([], 0, 0)
982
- )
983
-
984
- label = self._window_label_native(w, titles, raw_sessions, raw_events)
985
- await row.mount(
986
- Button(
987
- label,
988
- id=f"switch-{safe}",
989
- name=w.session_id,
990
- classes=switch_classes,
991
- )
992
- )
993
-
994
- can_toggle_ctx = bool(self._supports_context(w) and w.context_id and (raw_sessions or raw_events))
995
- expanded = bool(w.active and w.session_id == self._expanded_window_id)
996
- if w.active and can_toggle_ctx:
997
- await row.mount(
998
- Button(
999
- "▼" if expanded else "▶",
1000
- id=f"toggle-{safe}",
1001
- name=w.session_id,
1002
- variant="default",
1003
- classes="terminal-toggle",
1004
- )
1005
- )
1006
-
1007
- await row.mount(
1008
- Button(
1009
- "✕",
1010
- id=f"close-{safe}",
1011
- name=w.session_id,
1012
- variant="error",
1013
- classes="terminal-close",
1014
- )
1015
- )
1016
-
1017
- if w.active and self._supports_context(w) and w.context_id and expanded:
1018
- ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
1019
- await container.mount(ctx)
1020
- if loaded_ids:
1021
- for idx, sid in enumerate(loaded_ids):
1022
- title = titles.get(sid, "").strip() or "(no title)"
1023
- await ctx.mount(
1024
- Button(
1025
- f"{title} ({self._short_id(sid)})",
1026
- id=f"ctxsess-{safe}-{idx}",
1027
- name=sid,
1028
- variant="default",
1029
- classes="context-session",
1030
- )
1031
- )
1032
- else:
1033
- await ctx.mount(
1034
- Static(
1035
- "[dim]Context loaded, but session list isn't available (events not expanded).[/dim]"
1036
- )
1037
- )
1038
-
1039
- async def _render_terminals_tmux(
1040
- self,
1041
- windows: list[tmux_manager.InnerWindow],
1042
- titles: dict[str, str],
1043
- context_info_by_context_id: dict[str, tuple[list[str], int, int]],
1044
- ) -> None:
1045
- """Render terminal list for tmux backend."""
1046
- container = self.query_one("#terminals", Vertical)
1047
- await container.remove_children()
1048
-
1049
- if not windows:
1050
- await container.mount(
1051
- Static("No terminals yet. Click 'Create' to open a new agent terminal.")
1052
- )
1053
- return
1054
-
1055
- for w in windows:
1056
- safe = self._safe_id_fragment(w.window_id)
1057
- row = Horizontal(classes="terminal-row")
1058
- await container.mount(row)
1059
- # Show attention dot if window needs attention
1060
- if w.attention:
1061
- await row.mount(Static("●", classes="attention-dot"))
1062
- switch_classes = "terminal-switch active" if w.active else "terminal-switch"
1063
- loaded_ids: list[str] = []
1064
- raw_sessions = 0
1065
- raw_events = 0
1066
- if self._supports_context(w) and w.context_id:
1067
- loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
1068
- w.context_id, ([], 0, 0)
1069
- )
1070
- label = self._window_label_tmux(w, titles, raw_sessions, raw_events)
1071
- await row.mount(
1072
- Button(
1073
- label,
1074
- id=f"switch-{safe}",
1075
- name=w.window_id,
1076
- classes=switch_classes,
1077
- )
1078
- )
1079
- can_toggle_ctx = bool(self._supports_context(w) and w.context_id and (raw_sessions or raw_events))
1080
- expanded = bool(w.active and w.window_id == self._expanded_window_id)
1081
- if w.active and can_toggle_ctx:
1082
- await row.mount(
1083
- Button(
1084
- "▼" if expanded else "▶",
1085
- id=f"toggle-{safe}",
1086
- name=w.window_id,
1087
- variant="default",
1088
- classes="terminal-toggle",
1089
- )
1090
- )
1091
- await row.mount(
1092
- Button(
1093
- "✕",
1094
- id=f"close-{safe}",
1095
- name=w.window_id,
1096
- variant="error",
1097
- classes="terminal-close",
1098
- )
1099
- )
1100
-
1101
- if w.active and self._supports_context(w) and w.context_id and expanded:
1102
- ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
1103
- await container.mount(ctx)
1104
- if loaded_ids:
1105
- for idx, sid in enumerate(loaded_ids):
1106
- title = titles.get(sid, "").strip() or "(no title)"
1107
- await ctx.mount(
1108
- Button(
1109
- f"{title} ({self._short_id(sid)})",
1110
- id=f"ctxsess-{safe}-{idx}",
1111
- name=sid,
1112
- variant="default",
1113
- classes="context-session",
1114
- )
1115
- )
1116
- else:
1117
- await ctx.mount(
1118
- Static(
1119
- "[dim]Context loaded, but session list isn't available (events not expanded).[/dim]"
1120
- )
1121
- )
1122
-
1123
- @staticmethod
1124
- def _format_context_summary(session_count: int, event_count: int) -> str:
1125
- parts: list[str] = []
1126
- if session_count:
1127
- parts.append(f"{session_count}s")
1128
- if event_count:
1129
- parts.append(f"{event_count}e")
1130
- if not parts:
1131
- return "ctx 0"
1132
- return "ctx " + " ".join(parts)
1133
-
1134
- def _window_label_native(
1135
- self,
1136
- w: TerminalInfo,
1137
- titles: dict[str, str],
1138
- raw_sessions: int = 0,
1139
- raw_events: int = 0,
1140
- ) -> str | Text:
1141
- """Generate label for native terminal window."""
1142
- if not self._supports_context(w):
1143
- return Text(w.name, no_wrap=True, overflow="ellipsis")
1144
-
1145
- if self._is_codex_window(w):
1146
- details = Text(no_wrap=True, overflow="ellipsis")
1147
- details.append("Codex")
1148
- details.append("\n")
1149
- detail_line = "[Codex]"
1150
- if w.active:
1151
- loaded_count = raw_sessions + raw_events
1152
- detail_line = f"{detail_line} | loaded context: {loaded_count}"
1153
- else:
1154
- detail_line = (
1155
- f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1156
- )
1157
- if w.metadata.get("no_track") == "1":
1158
- detail_line = f"{detail_line} [NT]"
1159
- details.append(detail_line, style="dim not bold")
1160
- return details
1161
-
1162
- title = titles.get(w.claude_session_id or "", "").strip() if w.claude_session_id else ""
1163
- header = title or ("Claude" if w.claude_session_id else "New Claude")
1164
-
1165
- details = Text(no_wrap=True, overflow="ellipsis")
1166
- details.append(header)
1167
- details.append("\n")
1168
-
1169
- detail_line = "[Claude]"
1170
- if w.claude_session_id:
1171
- detail_line = f"{detail_line} #{self._short_id(w.claude_session_id)}"
1172
- if w.active:
1173
- loaded_count = raw_sessions + raw_events
1174
- detail_line = f"{detail_line} | loaded context: {loaded_count}"
1175
- else:
1176
- detail_line = (
1177
- f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1178
- )
1179
- # Show no-track indicator
1180
- if w.metadata.get("no_track") == "1":
1181
- detail_line = f"{detail_line} [NT]"
1182
- details.append(detail_line, style="dim not bold")
1183
- return details
1184
-
1185
- def _window_label_tmux(
1186
- self,
1187
- w: tmux_manager.InnerWindow,
1188
- titles: dict[str, str],
1189
- raw_sessions: int = 0,
1190
- raw_events: int = 0,
1191
- ) -> str | Text:
1192
- """Generate label for tmux window."""
1193
- if not self._supports_context(w):
1194
- return Text(w.window_name, no_wrap=True, overflow="ellipsis")
1195
-
1196
- if self._is_codex_window(w):
1197
- title = titles.get(w.session_id or "", "").strip() if w.session_id else ""
1198
- header = title or ("Codex" if w.session_id else "New Codex")
1199
-
1200
- details = Text(no_wrap=True, overflow="ellipsis")
1201
- details.append(header)
1202
- details.append("\n")
1203
-
1204
- detail_line = "[Codex]"
1205
- if w.session_id:
1206
- detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
1207
- if w.active:
1208
- loaded_count = raw_sessions + raw_events
1209
- detail_line = f"{detail_line} | loaded context: {loaded_count}"
1210
- else:
1211
- detail_line = (
1212
- f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1213
- )
1214
- if w.no_track:
1215
- detail_line = f"{detail_line} [NT]"
1216
- details.append(detail_line, style="dim not bold")
1217
- return details
1218
-
1219
- title = titles.get(w.session_id or "", "").strip() if w.session_id else ""
1220
- header = title or ("Claude" if w.session_id else "New Claude")
1221
-
1222
- details = Text(no_wrap=True, overflow="ellipsis")
1223
- details.append(header)
1224
- details.append("\n")
1225
-
1226
- detail_line = "[Claude]"
1227
- if w.session_id:
1228
- detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
1229
- if w.active:
1230
- loaded_count = raw_sessions + raw_events
1231
- detail_line = f"{detail_line} | loaded context: {loaded_count}"
1232
- else:
1233
- detail_line = (
1234
- f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1235
- )
1236
- # Show no-track indicator
1237
- if w.no_track:
1238
- detail_line = f"{detail_line} [NT]"
1239
- details.append(detail_line, style="dim not bold")
1240
- return details
1241
-
1242
- @staticmethod
1243
- def _short_id(value: str) -> str:
1244
- value = str(value)
1245
- if len(value) > 20:
1246
- return value[:8] + "..." + value[-8:]
1247
- return value
1248
-
1249
- @staticmethod
1250
- def _safe_id_fragment(raw: str) -> str:
1251
- # Textual ids must match: [A-Za-z_][A-Za-z0-9_-]*
1252
- safe = re.sub(r"[^A-Za-z0-9_-]+", "-", raw).strip("-_")
1253
- if not safe:
1254
- return "w"
1255
- if safe[0].isdigit():
1256
- return f"w-{safe}"
1257
- return safe
1258
-
1259
- @staticmethod
1260
- def _command_in_directory(command: str, directory: str) -> str:
1261
- """Wrap a command to run in a specific directory."""
1262
- return f"cd {shlex.quote(directory)} && {command}"
1263
-
1264
- async def _quick_create_claude_agent(self) -> None:
1265
- """Quickly create a new Claude Code terminal with default settings.
1266
-
1267
- Uses the last workspace (or cwd) with normal permissions and tracking enabled.
1268
- """
1269
- from ..screens.create_agent import _load_last_workspace
1270
-
1271
- workspace = _load_last_workspace()
1272
- self.run_worker(
1273
- self._create_agent("claude", workspace, skip_permissions=False, no_track=False),
1274
- group="terminal-panel-create",
1275
- exclusive=True,
1276
- )
1277
-
1278
- def _on_create_agent_result(self, result: tuple[str, str, bool, bool] | None) -> None:
1279
- """Handle the result from CreateAgentScreen modal."""
1280
- if result is None:
1281
- return
1282
-
1283
- agent_type, workspace, skip_permissions, no_track = result
1284
-
1285
- # Capture self reference for use in the deferred callback
1286
- panel = self
1287
-
1288
- # Use app.call_later to defer worker creation until after the modal is dismissed.
1289
- # This ensures the modal screen is fully closed before the worker starts,
1290
- # preventing UI update conflicts between modal closing and terminal panel refresh.
1291
- def start_worker() -> None:
1292
- panel.run_worker(
1293
- panel._create_agent(
1294
- agent_type, workspace, skip_permissions=skip_permissions, no_track=no_track
1295
- ),
1296
- group="terminal-panel-create",
1297
- exclusive=True,
1298
- )
1299
-
1300
- self.app.call_later(start_worker)
1301
-
1302
- async def _create_agent(
1303
- self, agent_type: str, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1304
- ) -> None:
1305
- """Create a new agent terminal based on the selected type and workspace."""
1306
- if agent_type == "claude":
1307
- await self._create_claude_terminal(workspace, skip_permissions=skip_permissions, no_track=no_track)
1308
- elif agent_type == "codex":
1309
- await self._create_codex_terminal(workspace, no_track=no_track)
1310
- elif agent_type == "opencode":
1311
- await self._create_opencode_terminal(workspace)
1312
- elif agent_type == "zsh":
1313
- await self._create_zsh_terminal(workspace)
1314
- # Schedule refresh in a separate worker to avoid blocking UI.
1315
- # The refresh involves slow synchronous operations (DB queries, file scans)
1316
- # that would otherwise freeze the dashboard.
1317
- self.run_worker(
1318
- self.refresh_data(),
1319
- group="terminal-panel-refresh",
1320
- exclusive=True,
1321
- )
1322
-
1323
- async def _create_claude_terminal(
1324
- self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1325
- ) -> None:
1326
- """Create a new Claude terminal."""
1327
- if self._is_native_mode():
1328
- await self._create_claude_terminal_native(workspace, skip_permissions=skip_permissions, no_track=no_track)
1329
- else:
1330
- await self._create_claude_terminal_tmux(workspace, skip_permissions=skip_permissions, no_track=no_track)
1331
-
1332
- async def _create_claude_terminal_native(
1333
- self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1334
- ) -> None:
1335
- """Create a new Claude terminal using native backend."""
1336
- backend = await self._ensure_native_backend()
1337
- if not backend:
1338
- self.app.notify(
1339
- "Native terminal backend not available",
1340
- title="Terminal",
1341
- severity="error",
1342
- )
1343
- return
1344
-
1345
- terminal_id = tmux_manager.new_terminal_id()
1346
- context_id = tmux_manager.new_context_id("cc")
1347
-
1348
- env = {
1349
- tmux_manager.ENV_TERMINAL_ID: terminal_id,
1350
- tmux_manager.ENV_TERMINAL_PROVIDER: "claude",
1351
- tmux_manager.ENV_CONTEXT_ID: context_id,
1352
- }
1353
- if no_track:
1354
- env["ALINE_NO_TRACK"] = "1"
1355
-
1356
- # Install hooks
1357
- self._install_claude_hooks(workspace)
1358
-
1359
- claude_cmd = "claude"
1360
- if skip_permissions:
1361
- claude_cmd = "claude --dangerously-skip-permissions"
1362
-
1363
- session_id = await backend.create_tab(
1364
- command=claude_cmd,
1365
- terminal_id=terminal_id,
1366
- name="Claude Code",
1367
- env=env,
1368
- cwd=workspace,
1369
- )
1370
-
1371
- if not session_id:
1372
- self.app.notify(
1373
- "Failed to open Claude terminal",
1374
- title="Terminal",
1375
- severity="error",
1376
- )
1377
-
1378
- async def _create_claude_terminal_tmux(
1379
- self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1380
- ) -> None:
1381
- """Create a new Claude terminal using tmux backend."""
1382
- terminal_id = tmux_manager.new_terminal_id()
1383
- context_id = tmux_manager.new_context_id("cc")
1384
- env = {
1385
- tmux_manager.ENV_TERMINAL_ID: terminal_id,
1386
- tmux_manager.ENV_TERMINAL_PROVIDER: "claude",
1387
- tmux_manager.ENV_INNER_SOCKET: tmux_manager.INNER_SOCKET,
1388
- tmux_manager.ENV_INNER_SESSION: tmux_manager.INNER_SESSION,
1389
- tmux_manager.ENV_CONTEXT_ID: context_id,
1390
- }
1391
- if no_track:
1392
- env["ALINE_NO_TRACK"] = "1"
1393
-
1394
- # Install hooks
1395
- self._install_claude_hooks(workspace)
1396
-
1397
- claude_cmd = "claude"
1398
- if skip_permissions:
1399
- claude_cmd = "claude --dangerously-skip-permissions"
1400
- command = self._command_in_directory(
1401
- tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
1402
- )
1403
- created = tmux_manager.create_inner_window(
1404
- "cc",
1405
- tmux_manager.shell_command_with_env(command, env),
1406
- terminal_id=terminal_id,
1407
- provider="claude",
1408
- context_id=context_id,
1409
- no_track=no_track,
1410
- )
1411
- if not created:
1412
- self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
1413
-
1414
- def _install_claude_hooks(self, workspace: str) -> None:
1415
- """Install Claude hooks for a workspace."""
1416
- try:
1417
- from ...claude_hooks.stop_hook_installer import (
1418
- ensure_stop_hook_installed,
1419
- get_settings_path as get_stop_settings_path,
1420
- install_stop_hook,
1421
- )
1422
- from ...claude_hooks.user_prompt_submit_hook_installer import (
1423
- ensure_user_prompt_submit_hook_installed,
1424
- get_settings_path as get_submit_settings_path,
1425
- install_user_prompt_submit_hook,
1426
- )
1427
- from ...claude_hooks.permission_request_hook_installer import (
1428
- ensure_permission_request_hook_installed,
1429
- get_settings_path as get_permission_settings_path,
1430
- install_permission_request_hook,
1431
- )
1432
-
1433
- ok_global_stop = ensure_stop_hook_installed(quiet=True)
1434
- ok_global_submit = ensure_user_prompt_submit_hook_installed(quiet=True)
1435
- ok_global_permission = ensure_permission_request_hook_installed(quiet=True)
1436
-
1437
- project_root = Path(workspace)
1438
- ok_project_stop = install_stop_hook(
1439
- get_stop_settings_path(project_root), quiet=True
1440
- )
1441
- ok_project_submit = install_user_prompt_submit_hook(
1442
- get_submit_settings_path(project_root), quiet=True
1443
- )
1444
- ok_project_permission = install_permission_request_hook(
1445
- get_permission_settings_path(project_root), quiet=True
1446
- )
1447
-
1448
- all_hooks_ok = (
1449
- ok_global_stop
1450
- and ok_global_submit
1451
- and ok_global_permission
1452
- and ok_project_stop
1453
- and ok_project_submit
1454
- and ok_project_permission
1455
- )
1456
- if not all_hooks_ok:
1457
- self.app.notify(
1458
- "Claude hooks not fully installed; session id/title may not update",
1459
- title="Terminal",
1460
- severity="warning",
1461
- )
1462
- except Exception:
1463
- pass
1464
-
1465
- async def _create_codex_terminal(self, workspace: str, *, no_track: bool = False) -> None:
1466
- """Create a new Codex terminal."""
1467
- terminal_id = tmux_manager.new_terminal_id()
1468
- context_id = tmux_manager.new_context_id("cx")
1469
-
1470
- # Use per-terminal CODEX_HOME so sessions/config are isolated and binding is deterministic.
1471
- try:
1472
- from ...codex_home import prepare_codex_home
1473
-
1474
- codex_home = prepare_codex_home(terminal_id)
1475
- except Exception:
1476
- codex_home = None
1477
-
1478
- env = {
1479
- tmux_manager.ENV_TERMINAL_ID: terminal_id,
1480
- tmux_manager.ENV_TERMINAL_PROVIDER: "codex",
1481
- tmux_manager.ENV_CONTEXT_ID: context_id,
1482
- }
1483
- if codex_home is not None:
1484
- env["CODEX_HOME"] = str(codex_home)
1485
- if no_track:
1486
- env["ALINE_NO_TRACK"] = "1"
1487
-
1488
- # Persist agent early so the watcher can bind the Codex session file back to this terminal.
1489
- try:
1490
- from ...db import get_database
1491
-
1492
- db = get_database(read_only=False)
1493
- db.get_or_create_agent(
1494
- terminal_id,
1495
- provider="codex",
1496
- session_type="codex",
1497
- context_id=context_id,
1498
- cwd=workspace,
1499
- project_dir=workspace,
1500
- source="dashboard",
1501
- )
1502
- except Exception:
1503
- pass
1504
-
1505
- if self._is_native_mode():
1506
- backend = await self._ensure_native_backend()
1507
- if backend:
1508
- session_id = await backend.create_tab(
1509
- command="codex",
1510
- terminal_id=terminal_id,
1511
- name="Codex",
1512
- env=env,
1513
- cwd=workspace,
1514
- )
1515
- if not session_id:
1516
- self.app.notify(
1517
- "Failed to open Codex terminal", title="Terminal", severity="error"
1518
- )
1519
- return
1520
-
1521
- # Tmux fallback
1522
- command = self._command_in_directory(
1523
- tmux_manager.zsh_run_and_keep_open("codex"), workspace
1524
- )
1525
- created = tmux_manager.create_inner_window(
1526
- "codex",
1527
- tmux_manager.shell_command_with_env(command, env),
1528
- terminal_id=terminal_id,
1529
- provider="codex",
1530
- context_id=context_id,
1531
- no_track=no_track,
1532
- )
1533
- if not created:
1534
- self.app.notify("Failed to open Codex terminal", title="Terminal", severity="error")
1535
-
1536
- async def _create_opencode_terminal(self, workspace: str) -> None:
1537
- """Create a new Opencode terminal."""
1538
- if self._is_native_mode():
1539
- backend = await self._ensure_native_backend()
1540
- if backend:
1541
- terminal_id = tmux_manager.new_terminal_id()
1542
- session_id = await backend.create_tab(
1543
- command="opencode",
1544
- terminal_id=terminal_id,
1545
- name="Opencode",
1546
- cwd=workspace,
1547
- )
1548
- if not session_id:
1549
- self.app.notify(
1550
- "Failed to open Opencode terminal", title="Terminal", severity="error"
1551
- )
1552
- return
1553
-
1554
- # Tmux fallback
1555
- command = self._command_in_directory(
1556
- tmux_manager.zsh_run_and_keep_open("opencode"), workspace
1557
- )
1558
- created = tmux_manager.create_inner_window("opencode", command)
1559
- if not created:
1560
- self.app.notify("Failed to open Opencode terminal", title="Terminal", severity="error")
1561
-
1562
- async def _create_zsh_terminal(self, workspace: str) -> None:
1563
- """Create a new zsh terminal."""
1564
- t0 = time.time()
1565
- logger.info(f"[PERF] _create_zsh_terminal START")
1566
- if self._is_native_mode():
1567
- backend = await self._ensure_native_backend()
1568
- if backend:
1569
- terminal_id = tmux_manager.new_terminal_id()
1570
- session_id = await backend.create_tab(
1571
- command="zsh -l",
1572
- terminal_id=terminal_id,
1573
- name="zsh",
1574
- cwd=workspace,
1575
- )
1576
- if not session_id:
1577
- self.app.notify(
1578
- "Failed to open zsh terminal", title="Terminal", severity="error"
1579
- )
1580
- logger.info(f"[PERF] _create_zsh_terminal native END: {time.time() - t0:.3f}s")
1581
- return
1582
-
1583
- # Tmux fallback
1584
- t1 = time.time()
1585
- command = self._command_in_directory("zsh", workspace)
1586
- logger.info(f"[PERF] _create_zsh_terminal command ready: {time.time() - t1:.3f}s")
1587
- t2 = time.time()
1588
- created = tmux_manager.create_inner_window("zsh", command)
1589
- logger.info(f"[PERF] _create_zsh_terminal create_inner_window: {time.time() - t2:.3f}s")
1590
- if not created:
1591
- self.app.notify("Failed to open zsh terminal", title="Terminal", severity="error")
1592
- logger.info(f"[PERF] _create_zsh_terminal TOTAL: {time.time() - t0:.3f}s")
1593
-
1594
- async def on_button_pressed(self, event: Button.Pressed) -> None:
1595
- button_id = event.button.id or ""
1596
-
1597
- if not self.supported():
1598
- self.app.notify(
1599
- self._support_message(),
1600
- title="Terminal",
1601
- severity="warning",
1602
- )
1603
- return
1604
-
1605
- if button_id == "quick-new-agent":
1606
- await self._quick_create_claude_agent()
1607
- return
1608
-
1609
- if button_id == "new-agent":
1610
- from ..screens import CreateAgentScreen
1611
-
1612
- self.app.push_screen(CreateAgentScreen(), self._on_create_agent_result)
1613
- return
1614
-
1615
- if button_id.startswith("switch-"):
1616
- await self._handle_switch(event.button.name or "")
1617
- return
1618
-
1619
- if button_id.startswith("toggle-"):
1620
- window_id = event.button.name or ""
1621
- if not window_id:
1622
- return
1623
- if self._expanded_window_id == window_id:
1624
- self._expanded_window_id = None
1625
- else:
1626
- self._expanded_window_id = window_id
1627
- await self.refresh_data()
1628
- return
1629
-
1630
- if button_id.startswith("ctxsess-"):
1631
- session_id = (event.button.name or "").strip()
1632
- if not session_id:
1633
- return
1634
- try:
1635
- from ..screens import SessionDetailScreen
1636
-
1637
- self.app.push_screen(SessionDetailScreen(session_id))
1638
- except Exception:
1639
- pass
1640
- return
1641
-
1642
- if button_id.startswith("close-"):
1643
- await self._handle_close(event.button.name or "")
1644
- return
1645
-
1646
- async def _handle_switch(self, window_id: str) -> None:
1647
- """Handle switching to a terminal."""
1648
- if not window_id:
1649
- return
1650
-
1651
- if self._is_native_mode():
1652
- backend = await self._ensure_native_backend()
1653
- if backend:
1654
- success = await backend.focus_tab(window_id, steal_focus=True)
1655
- if not success:
1656
- self.app.notify(
1657
- "Failed to switch terminal", title="Terminal", severity="error"
1658
- )
1659
- else:
1660
- if not tmux_manager.select_inner_window(window_id):
1661
- self.app.notify("Failed to switch terminal", title="Terminal", severity="error")
1662
- else:
1663
- # Move cursor focus to the right pane (terminal area)
1664
- tmux_manager.focus_right_pane()
1665
- # Clear attention when user clicks on terminal
1666
- tmux_manager.clear_attention(window_id)
1667
-
1668
- self._expanded_window_id = None
1669
- await self.refresh_data()
1670
-
1671
- async def _handle_close(self, window_id: str) -> None:
1672
- """Handle closing a terminal."""
1673
- if not window_id:
1674
- return
1675
-
1676
- if self._is_native_mode():
1677
- backend = await self._ensure_native_backend()
1678
- if backend:
1679
- success = await backend.close_tab(window_id)
1680
- if not success:
1681
- self.app.notify(
1682
- "Failed to close terminal", title="Terminal", severity="error"
1683
- )
1684
- else:
1685
- if not tmux_manager.kill_inner_window(window_id):
1686
- self.app.notify("Failed to close terminal", title="Terminal", severity="error")
1687
-
1688
- await self.refresh_data()