aline-ai 0.6.5__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.
Files changed (42) hide show
  1. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/RECORD +41 -34
  3. realign/__init__.py +1 -1
  4. realign/agent_names.py +79 -0
  5. realign/claude_hooks/stop_hook.py +3 -0
  6. realign/claude_hooks/terminal_state.py +43 -1
  7. realign/claude_hooks/user_prompt_submit_hook.py +3 -0
  8. realign/cli.py +62 -0
  9. realign/codex_detector.py +18 -3
  10. realign/codex_home.py +65 -16
  11. realign/codex_terminal_linker.py +18 -7
  12. realign/commands/agent.py +109 -0
  13. realign/commands/doctor.py +74 -1
  14. realign/commands/export_shares.py +448 -0
  15. realign/commands/import_shares.py +203 -1
  16. realign/commands/search.py +58 -29
  17. realign/commands/sync_agent.py +347 -0
  18. realign/dashboard/app.py +9 -9
  19. realign/dashboard/clipboard.py +54 -0
  20. realign/dashboard/screens/__init__.py +4 -0
  21. realign/dashboard/screens/agent_detail.py +333 -0
  22. realign/dashboard/screens/create_agent_info.py +244 -0
  23. realign/dashboard/screens/event_detail.py +6 -27
  24. realign/dashboard/styles/dashboard.tcss +22 -28
  25. realign/dashboard/tmux_manager.py +36 -10
  26. realign/dashboard/widgets/__init__.py +2 -2
  27. realign/dashboard/widgets/agents_panel.py +1248 -0
  28. realign/dashboard/widgets/events_table.py +4 -27
  29. realign/dashboard/widgets/sessions_table.py +4 -27
  30. realign/db/base.py +69 -0
  31. realign/db/locks.py +4 -0
  32. realign/db/schema.py +111 -2
  33. realign/db/sqlite_db.py +360 -2
  34. realign/events/agent_summarizer.py +157 -0
  35. realign/events/session_summarizer.py +25 -0
  36. realign/watcher_core.py +193 -5
  37. realign/worker_core.py +59 -1
  38. realign/dashboard/widgets/terminal_panel.py +0 -1653
  39. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/WHEEL +0 -0
  40. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/entry_points.txt +0 -0
  41. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/licenses/LICENSE +0 -0
  42. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/top_level.txt +0 -0
@@ -1,1653 +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
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
- sessions_root = codex_sessions_dir_for_terminal(terminal_id)
478
- if sessions_root.exists():
479
- # Deterministic: isolated per-terminal CODEX_HOME.
480
- try:
481
- candidates = list(sessions_root.rglob("rollout-*.jsonl"))
482
- except Exception:
483
- candidates = []
484
- else:
485
- # Fallback for legacy terminals not launched with isolated CODEX_HOME.
486
- try:
487
- from ...codex_detector import find_codex_sessions_for_project
488
-
489
- candidates = find_codex_sessions_for_project(Path(cwd), days_back=3)
490
- except Exception:
491
- candidates = []
492
- if not candidates:
493
- return
494
-
495
- created_dt: datetime | None = None
496
- if created_at is not None:
497
- try:
498
- created_dt = datetime.fromtimestamp(float(created_at), tz=timezone.utc)
499
- except Exception:
500
- created_dt = None
501
-
502
- best: Path | None = None
503
- best_score: float | None = None
504
- candidates.sort(key=lambda p: p.stat().st_mtime if p.exists() else 0, reverse=True)
505
- for session_file in candidates[:200]:
506
- meta = read_codex_session_meta(session_file)
507
- if meta is None or (meta.cwd or "").strip() != cwd:
508
- continue
509
-
510
- started_dt: datetime | None = meta.started_at
511
- if started_dt is None:
512
- try:
513
- started_dt = datetime.fromtimestamp(
514
- session_file.stat().st_mtime, tz=timezone.utc
515
- )
516
- except Exception:
517
- started_dt = None
518
- if started_dt is None:
519
- continue
520
-
521
- if created_dt is not None:
522
- delta = abs((started_dt - created_dt).total_seconds())
523
- else:
524
- try:
525
- delta = abs(time.time() - session_file.stat().st_mtime)
526
- except Exception:
527
- continue
528
-
529
- penalty = 0.0
530
- origin = (meta.originator or "").lower()
531
- if "vscode" in origin:
532
- penalty += 3600.0
533
- score = float(delta) + penalty
534
-
535
- if best_score is None or score < best_score:
536
- best_score = score
537
- best = session_file
538
-
539
- if not best:
540
- return
541
-
542
- # Avoid binding wildly unrelated sessions.
543
- if best_score is not None and best_score > 6 * 60 * 60:
544
- return
545
-
546
- try:
547
- db.update_agent(
548
- terminal_id,
549
- provider="codex",
550
- session_type="codex",
551
- session_id=best.stem,
552
- transcript_path=str(best),
553
- cwd=cwd,
554
- project_dir=cwd,
555
- source="dashboard:auto-link",
556
- )
557
- except Exception:
558
- return
559
-
560
- def __init__(self, use_native_terminal: bool | None = None) -> None:
561
- """Initialize the terminal panel.
562
-
563
- Args:
564
- use_native_terminal: If True, use native terminal backend.
565
- If False, use tmux.
566
- If None (default), auto-detect from environment.
567
- """
568
- super().__init__()
569
- self._refresh_lock = asyncio.Lock()
570
- self._expanded_window_id: str | None = None
571
- self._signal_watcher: _SignalFileWatcher | None = None
572
-
573
- # Determine terminal mode
574
- if use_native_terminal is True:
575
- self._mode = _detect_terminal_mode()
576
- if self._mode == MODE_TMUX:
577
- self._mode = MODE_ITERM2 # Default to iTerm2 if native requested
578
- elif use_native_terminal is False:
579
- self._mode = MODE_TMUX
580
- else:
581
- self._mode = _detect_terminal_mode()
582
-
583
- # Native backend (initialized lazily)
584
- self._native_backend: TerminalBackend | None = None
585
- self._native_backend_checked = False
586
-
587
- # Best-effort Codex session binding without requiring the watcher process.
588
- self._codex_link_last_attempt: dict[str, float] = {}
589
-
590
- def compose(self) -> ComposeResult:
591
- logger.debug("TerminalPanel.compose() started")
592
- try:
593
- controls_enabled = self.supported()
594
- with Horizontal(classes="summary"):
595
- yield Button(
596
- "+ Create",
597
- id="new-agent",
598
- variant="primary",
599
- disabled=not controls_enabled,
600
- )
601
- with Vertical(id="terminals", classes="list"):
602
- if controls_enabled:
603
- yield Static(
604
- "No terminals yet. Click 'Create' to open a new agent terminal."
605
- )
606
- else:
607
- yield Static(self._support_message())
608
- logger.debug("TerminalPanel.compose() completed")
609
- except Exception as e:
610
- logger.error(f"TerminalPanel.compose() failed: {e}\n{traceback.format_exc()}")
611
- raise
612
-
613
- def on_show(self) -> None:
614
- self.call_after_refresh(
615
- lambda: self.run_worker(
616
- self.refresh_data(),
617
- group="terminal-panel-refresh",
618
- exclusive=True,
619
- )
620
- )
621
- self._start_signal_watcher()
622
-
623
- def on_hide(self) -> None:
624
- self._stop_signal_watcher()
625
-
626
- def _start_signal_watcher(self) -> None:
627
- """Start watching for permission request signal files."""
628
- if self._signal_watcher is not None:
629
- return
630
- self._signal_watcher = _SignalFileWatcher(self._on_permission_signal)
631
- self._signal_watcher.start()
632
-
633
- def _stop_signal_watcher(self) -> None:
634
- """Stop watching for permission request signal files."""
635
- if self._signal_watcher is not None:
636
- self._signal_watcher.stop()
637
- self._signal_watcher = None
638
-
639
- def _on_permission_signal(self) -> None:
640
- """Called when a new permission request signal is detected."""
641
- self.post_message(self.PermissionRequestDetected())
642
-
643
- def on_terminal_panel_permission_request_detected(
644
- self, event: PermissionRequestDetected
645
- ) -> None:
646
- """Handle permission request detection - refresh the terminal list."""
647
- self.run_worker(
648
- self.refresh_data(),
649
- group="terminal-panel-refresh",
650
- exclusive=True,
651
- )
652
-
653
- async def _ensure_native_backend(self) -> TerminalBackend | None:
654
- """Ensure native backend is initialized."""
655
- if self._native_backend_checked:
656
- return self._native_backend
657
-
658
- self._native_backend_checked = True
659
-
660
- if self._mode in {MODE_ITERM2, MODE_KITTY}:
661
- self._native_backend = await _get_native_backend(self._mode)
662
-
663
- return self._native_backend
664
-
665
- def _is_native_mode(self) -> bool:
666
- """Check if we're using native terminal mode."""
667
- return self._mode in {MODE_ITERM2, MODE_KITTY}
668
-
669
- async def refresh_data(self) -> None:
670
- async with self._refresh_lock:
671
- t_start = time.time()
672
- # Check and close stale terminals if enabled
673
- await self._close_stale_terminals_if_enabled()
674
- logger.debug(f"[PERF] _close_stale_terminals_if_enabled: {time.time() - t_start:.3f}s")
675
-
676
- t_refresh = time.time()
677
- if self._is_native_mode():
678
- await self._refresh_native_data()
679
- else:
680
- await self._refresh_tmux_data()
681
- logger.debug(f"[PERF] total refresh: {time.time() - t_start:.3f}s")
682
-
683
- async def _close_stale_terminals_if_enabled(self) -> None:
684
- """Close terminals that haven't been updated for the configured hours."""
685
- try:
686
- from ...config import ReAlignConfig
687
-
688
- config = ReAlignConfig.load()
689
- if not config.auto_close_stale_terminals:
690
- return
691
-
692
- stale_hours = config.stale_terminal_hours or 24
693
- cutoff_time = datetime.now() - timedelta(hours=stale_hours)
694
-
695
- # Get stale agents from database
696
- from ...db import get_database
697
-
698
- db = get_database(read_only=True)
699
- all_agents = db.list_agents(status="active", limit=1000)
700
-
701
- stale_agent_ids = set()
702
- for agent in all_agents:
703
- if agent.updated_at and agent.updated_at < cutoff_time:
704
- stale_agent_ids.add(agent.id)
705
-
706
- if not stale_agent_ids:
707
- return
708
-
709
- # Get current windows
710
- if self._is_native_mode():
711
- backend = await self._ensure_native_backend()
712
- if not backend:
713
- return
714
- windows = await backend.list_tabs()
715
- for w in windows:
716
- if w.terminal_id in stale_agent_ids:
717
- logger.info(f"Auto-closing stale terminal: {w.terminal_id}")
718
- await backend.close_tab(w.session_id)
719
- else:
720
- windows = tmux_manager.list_inner_windows()
721
- for w in windows:
722
- if w.terminal_id in stale_agent_ids:
723
- logger.info(f"Auto-closing stale terminal: {w.terminal_id}")
724
- tmux_manager.kill_inner_window(w.window_id)
725
-
726
- except Exception as e:
727
- logger.debug(f"Error checking stale terminals: {e}")
728
-
729
- async def _refresh_native_data(self) -> None:
730
- """Refresh data using native terminal backend."""
731
- backend = await self._ensure_native_backend()
732
- if not backend:
733
- # Fall back to showing error message
734
- try:
735
- container = self.query_one("#terminals", Vertical)
736
- await container.remove_children()
737
- await container.mount(Static(self._support_message()))
738
- except Exception:
739
- pass
740
- return
741
-
742
- try:
743
- windows = await backend.list_tabs()
744
- except Exception as e:
745
- logger.error(f"Failed to list native terminals: {e}")
746
- return
747
-
748
- # Yield to event loop to keep UI responsive
749
- await asyncio.sleep(0)
750
-
751
- # NOTE: _maybe_link_codex_session_for_terminal is intentionally skipped here
752
- # because it performs expensive file system scans (find_codex_sessions_for_project)
753
- # that can take minutes with many session files. Codex session linking is handled
754
- # by the watcher process instead.
755
-
756
- active_window_id = next(
757
- (w.session_id for w in windows if w.active), None
758
- )
759
- if self._expanded_window_id and self._expanded_window_id != active_window_id:
760
- self._expanded_window_id = None
761
-
762
- # Titles (best-effort; native terminals only expose Claude session ids today)
763
- claude_ids = [
764
- w.claude_session_id
765
- for w in windows
766
- if self._is_claude_window(w) and w.claude_session_id
767
- ]
768
- titles = self._fetch_claude_session_titles(claude_ids)
769
-
770
- # Yield to event loop after DB query
771
- await asyncio.sleep(0)
772
-
773
- # Get context info
774
- context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
775
- all_context_session_ids: set[str] = set()
776
- for w in windows:
777
- if not self._supports_context(w) or not w.context_id:
778
- continue
779
- session_ids, session_count, event_count = self._get_loaded_context_info(
780
- w.context_id
781
- )
782
- if not session_ids and session_count == 0 and event_count == 0:
783
- continue
784
- context_info_by_context_id[w.context_id] = (
785
- session_ids,
786
- session_count,
787
- event_count,
788
- )
789
- all_context_session_ids.update(session_ids)
790
-
791
- if all_context_session_ids:
792
- titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
793
-
794
- try:
795
- await self._render_terminals_native(windows, titles, context_info_by_context_id)
796
- except Exception:
797
- return
798
-
799
- async def _refresh_tmux_data(self) -> None:
800
- """Refresh data using tmux backend."""
801
- t0 = time.time()
802
- try:
803
- supported = self.supported()
804
- except Exception:
805
- return
806
-
807
- if not supported:
808
- return
809
-
810
- try:
811
- windows = tmux_manager.list_inner_windows()
812
- except Exception:
813
- return
814
- windows = [w for w in windows if not self._is_internal_tmux_window(w)]
815
- logger.debug(f"[PERF] list_inner_windows: {time.time() - t0:.3f}s")
816
-
817
- # Yield to event loop to keep UI responsive
818
- await asyncio.sleep(0)
819
-
820
- # NOTE: _maybe_link_codex_session_for_terminal is intentionally skipped here
821
- # because it performs expensive file system scans (find_codex_sessions_for_project)
822
- # that can take minutes with many session files. Codex session linking is handled
823
- # by the watcher process instead.
824
-
825
- active_window_id = next((w.window_id for w in windows if w.active), None)
826
- if self._expanded_window_id and self._expanded_window_id != active_window_id:
827
- self._expanded_window_id = None
828
-
829
- t1 = time.time()
830
- session_ids = [w.session_id for w in windows if self._supports_context(w) and w.session_id]
831
- titles = self._fetch_claude_session_titles(session_ids)
832
- logger.debug(f"[PERF] fetch_claude_session_titles: {time.time() - t1:.3f}s")
833
-
834
- # Yield to event loop after DB query
835
- await asyncio.sleep(0)
836
-
837
- t2 = time.time()
838
- context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
839
- all_context_session_ids: set[str] = set()
840
- for w in windows:
841
- if not self._supports_context(w) or not w.context_id:
842
- continue
843
- session_ids, session_count, event_count = self._get_loaded_context_info(
844
- w.context_id
845
- )
846
- if not session_ids and session_count == 0 and event_count == 0:
847
- continue
848
- context_info_by_context_id[w.context_id] = (
849
- session_ids,
850
- session_count,
851
- event_count,
852
- )
853
- all_context_session_ids.update(session_ids)
854
- # Yield periodically during context info gathering
855
- await asyncio.sleep(0)
856
- logger.debug(f"[PERF] get_loaded_context_info loop: {time.time() - t2:.3f}s")
857
-
858
- t3 = time.time()
859
- if all_context_session_ids:
860
- titles.update(self._fetch_claude_session_titles(sorted(all_context_session_ids)))
861
- logger.debug(f"[PERF] fetch context session titles: {time.time() - t3:.3f}s")
862
-
863
- t4 = time.time()
864
- try:
865
- await self._render_terminals_tmux(windows, titles, context_info_by_context_id)
866
- except Exception:
867
- return
868
- logger.debug(f"[PERF] render_terminals_tmux: {time.time() - t4:.3f}s")
869
-
870
- def _fetch_claude_session_titles(self, session_ids: list[str]) -> dict[str, str]:
871
- # Back-compat hook for tests and older call sites.
872
- return self._fetch_session_titles(session_ids)
873
-
874
- def _fetch_session_titles(self, session_ids: list[str]) -> dict[str, str]:
875
- if not session_ids:
876
- return {}
877
- try:
878
- from ...db import get_database
879
-
880
- db = get_database(read_only=True)
881
- sessions = db.get_sessions_by_ids(session_ids)
882
- titles: dict[str, str] = {}
883
- for s in sessions:
884
- title = (s.session_title or "").strip()
885
- if title:
886
- titles[s.id] = title
887
- return titles
888
- except Exception:
889
- return {}
890
-
891
- def _get_loaded_context_info(self, context_id: str) -> tuple[list[str], int, int]:
892
- """Best-effort: read ~/.aline/load.json for a context_id, and return its session ids."""
893
- context_id = (context_id or "").strip()
894
- if not context_id:
895
- return ([], 0, 0)
896
- try:
897
- from ...context import get_context_by_id, load_context_config
898
-
899
- config = load_context_config()
900
- if config is None:
901
- return ([], 0, 0)
902
- entry = get_context_by_id(context_id, config)
903
- if entry is None:
904
- return ([], 0, 0)
905
-
906
- raw_sessions: set[str] = set(
907
- str(s).strip() for s in (entry.context_sessions or []) if str(s).strip()
908
- )
909
- raw_events: list[str] = [
910
- str(e).strip() for e in (entry.context_events or []) if str(e).strip()
911
- ]
912
- raw_event_ids: set[str] = set(raw_events)
913
-
914
- out: set[str] = set(raw_sessions)
915
-
916
- if raw_events:
917
- try:
918
- from ...db import get_database
919
-
920
- db = get_database(read_only=True)
921
- for event_id in raw_events:
922
- try:
923
- sessions = db.get_sessions_for_event(str(event_id))
924
- out.update(s.id for s in sessions if getattr(s, "id", None))
925
- except Exception:
926
- continue
927
- except Exception:
928
- pass
929
-
930
- return (sorted(out), len(raw_sessions), len(raw_event_ids))
931
- except Exception:
932
- return ([], 0, 0)
933
-
934
- async def _render_terminals_native(
935
- self,
936
- windows: list[TerminalInfo],
937
- titles: dict[str, str],
938
- context_info_by_context_id: dict[str, tuple[list[str], int, int]],
939
- ) -> None:
940
- """Render terminal list for native backend."""
941
- container = self.query_one("#terminals", Vertical)
942
- await container.remove_children()
943
-
944
- if not windows:
945
- await container.mount(
946
- Static("No terminals yet. Click 'Create' to open a new agent terminal.")
947
- )
948
- return
949
-
950
- for w in windows:
951
- safe = self._safe_id_fragment(w.session_id)
952
- row = Horizontal(classes="terminal-row")
953
- await container.mount(row)
954
-
955
- if w.attention:
956
- await row.mount(Static("●", classes="attention-dot"))
957
-
958
- switch_classes = "terminal-switch active" if w.active else "terminal-switch"
959
- loaded_ids: list[str] = []
960
- raw_sessions = 0
961
- raw_events = 0
962
- if self._supports_context(w) and w.context_id:
963
- loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
964
- w.context_id, ([], 0, 0)
965
- )
966
-
967
- label = self._window_label_native(w, titles, raw_sessions, raw_events)
968
- await row.mount(
969
- Button(
970
- label,
971
- id=f"switch-{safe}",
972
- name=w.session_id,
973
- classes=switch_classes,
974
- )
975
- )
976
-
977
- can_toggle_ctx = bool(self._supports_context(w) and w.context_id and (raw_sessions or raw_events))
978
- expanded = bool(w.active and w.session_id == self._expanded_window_id)
979
- if w.active and can_toggle_ctx:
980
- await row.mount(
981
- Button(
982
- "▼" if expanded else "▶",
983
- id=f"toggle-{safe}",
984
- name=w.session_id,
985
- variant="default",
986
- classes="terminal-toggle",
987
- )
988
- )
989
-
990
- await row.mount(
991
- Button(
992
- "✕",
993
- id=f"close-{safe}",
994
- name=w.session_id,
995
- variant="error",
996
- classes="terminal-close",
997
- )
998
- )
999
-
1000
- if w.active and self._supports_context(w) and w.context_id and expanded:
1001
- ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
1002
- await container.mount(ctx)
1003
- if loaded_ids:
1004
- for idx, sid in enumerate(loaded_ids):
1005
- title = titles.get(sid, "").strip() or "(no title)"
1006
- await ctx.mount(
1007
- Button(
1008
- f"{title} ({self._short_id(sid)})",
1009
- id=f"ctxsess-{safe}-{idx}",
1010
- name=sid,
1011
- variant="default",
1012
- classes="context-session",
1013
- )
1014
- )
1015
- else:
1016
- await ctx.mount(
1017
- Static(
1018
- "[dim]Context loaded, but session list isn't available (events not expanded).[/dim]"
1019
- )
1020
- )
1021
-
1022
- async def _render_terminals_tmux(
1023
- self,
1024
- windows: list[tmux_manager.InnerWindow],
1025
- titles: dict[str, str],
1026
- context_info_by_context_id: dict[str, tuple[list[str], int, int]],
1027
- ) -> None:
1028
- """Render terminal list for tmux backend."""
1029
- container = self.query_one("#terminals", Vertical)
1030
- await container.remove_children()
1031
-
1032
- if not windows:
1033
- await container.mount(
1034
- Static("No terminals yet. Click 'Create' to open a new agent terminal.")
1035
- )
1036
- return
1037
-
1038
- for w in windows:
1039
- safe = self._safe_id_fragment(w.window_id)
1040
- row = Horizontal(classes="terminal-row")
1041
- await container.mount(row)
1042
- # Show attention dot if window needs attention
1043
- if w.attention:
1044
- await row.mount(Static("●", classes="attention-dot"))
1045
- switch_classes = "terminal-switch active" if w.active else "terminal-switch"
1046
- loaded_ids: list[str] = []
1047
- raw_sessions = 0
1048
- raw_events = 0
1049
- if self._supports_context(w) and w.context_id:
1050
- loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
1051
- w.context_id, ([], 0, 0)
1052
- )
1053
- label = self._window_label_tmux(w, titles, raw_sessions, raw_events)
1054
- await row.mount(
1055
- Button(
1056
- label,
1057
- id=f"switch-{safe}",
1058
- name=w.window_id,
1059
- classes=switch_classes,
1060
- )
1061
- )
1062
- can_toggle_ctx = bool(self._supports_context(w) and w.context_id and (raw_sessions or raw_events))
1063
- expanded = bool(w.active and w.window_id == self._expanded_window_id)
1064
- if w.active and can_toggle_ctx:
1065
- await row.mount(
1066
- Button(
1067
- "▼" if expanded else "▶",
1068
- id=f"toggle-{safe}",
1069
- name=w.window_id,
1070
- variant="default",
1071
- classes="terminal-toggle",
1072
- )
1073
- )
1074
- await row.mount(
1075
- Button(
1076
- "✕",
1077
- id=f"close-{safe}",
1078
- name=w.window_id,
1079
- variant="error",
1080
- classes="terminal-close",
1081
- )
1082
- )
1083
-
1084
- if w.active and self._supports_context(w) and w.context_id and expanded:
1085
- ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
1086
- await container.mount(ctx)
1087
- if loaded_ids:
1088
- for idx, sid in enumerate(loaded_ids):
1089
- title = titles.get(sid, "").strip() or "(no title)"
1090
- await ctx.mount(
1091
- Button(
1092
- f"{title} ({self._short_id(sid)})",
1093
- id=f"ctxsess-{safe}-{idx}",
1094
- name=sid,
1095
- variant="default",
1096
- classes="context-session",
1097
- )
1098
- )
1099
- else:
1100
- await ctx.mount(
1101
- Static(
1102
- "[dim]Context loaded, but session list isn't available (events not expanded).[/dim]"
1103
- )
1104
- )
1105
-
1106
- @staticmethod
1107
- def _format_context_summary(session_count: int, event_count: int) -> str:
1108
- parts: list[str] = []
1109
- if session_count:
1110
- parts.append(f"{session_count}s")
1111
- if event_count:
1112
- parts.append(f"{event_count}e")
1113
- if not parts:
1114
- return "ctx 0"
1115
- return "ctx " + " ".join(parts)
1116
-
1117
- def _window_label_native(
1118
- self,
1119
- w: TerminalInfo,
1120
- titles: dict[str, str],
1121
- raw_sessions: int = 0,
1122
- raw_events: int = 0,
1123
- ) -> str | Text:
1124
- """Generate label for native terminal window."""
1125
- if not self._supports_context(w):
1126
- return Text(w.name, no_wrap=True, overflow="ellipsis")
1127
-
1128
- if self._is_codex_window(w):
1129
- details = Text(no_wrap=True, overflow="ellipsis")
1130
- details.append("Codex")
1131
- details.append("\n")
1132
- detail_line = "[Codex]"
1133
- if w.active:
1134
- loaded_count = raw_sessions + raw_events
1135
- detail_line = f"{detail_line} | loaded context: {loaded_count}"
1136
- else:
1137
- detail_line = (
1138
- f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1139
- )
1140
- if w.metadata.get("no_track") == "1":
1141
- detail_line = f"{detail_line} [NT]"
1142
- details.append(detail_line, style="dim not bold")
1143
- return details
1144
-
1145
- title = titles.get(w.claude_session_id or "", "").strip() if w.claude_session_id else ""
1146
- header = title or ("Claude" if w.claude_session_id else "New Claude")
1147
-
1148
- details = Text(no_wrap=True, overflow="ellipsis")
1149
- details.append(header)
1150
- details.append("\n")
1151
-
1152
- detail_line = "[Claude]"
1153
- if w.claude_session_id:
1154
- detail_line = f"{detail_line} #{self._short_id(w.claude_session_id)}"
1155
- if w.active:
1156
- loaded_count = raw_sessions + raw_events
1157
- detail_line = f"{detail_line} | loaded context: {loaded_count}"
1158
- else:
1159
- detail_line = (
1160
- f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1161
- )
1162
- # Show no-track indicator
1163
- if w.metadata.get("no_track") == "1":
1164
- detail_line = f"{detail_line} [NT]"
1165
- details.append(detail_line, style="dim not bold")
1166
- return details
1167
-
1168
- def _window_label_tmux(
1169
- self,
1170
- w: tmux_manager.InnerWindow,
1171
- titles: dict[str, str],
1172
- raw_sessions: int = 0,
1173
- raw_events: int = 0,
1174
- ) -> str | Text:
1175
- """Generate label for tmux window."""
1176
- if not self._supports_context(w):
1177
- return Text(w.window_name, no_wrap=True, overflow="ellipsis")
1178
-
1179
- if self._is_codex_window(w):
1180
- title = titles.get(w.session_id or "", "").strip() if w.session_id else ""
1181
- header = title or ("Codex" if w.session_id else "New Codex")
1182
-
1183
- details = Text(no_wrap=True, overflow="ellipsis")
1184
- details.append(header)
1185
- details.append("\n")
1186
-
1187
- detail_line = "[Codex]"
1188
- if w.session_id:
1189
- detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
1190
- if w.active:
1191
- loaded_count = raw_sessions + raw_events
1192
- detail_line = f"{detail_line} | loaded context: {loaded_count}"
1193
- else:
1194
- detail_line = (
1195
- f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1196
- )
1197
- if w.no_track:
1198
- detail_line = f"{detail_line} [NT]"
1199
- details.append(detail_line, style="dim not bold")
1200
- return details
1201
-
1202
- title = titles.get(w.session_id or "", "").strip() if w.session_id else ""
1203
- header = title or ("Claude" if w.session_id else "New Claude")
1204
-
1205
- details = Text(no_wrap=True, overflow="ellipsis")
1206
- details.append(header)
1207
- details.append("\n")
1208
-
1209
- detail_line = "[Claude]"
1210
- if w.session_id:
1211
- detail_line = f"{detail_line} #{self._short_id(w.session_id)}"
1212
- if w.active:
1213
- loaded_count = raw_sessions + raw_events
1214
- detail_line = f"{detail_line} | loaded context: {loaded_count}"
1215
- else:
1216
- detail_line = (
1217
- f"{detail_line} · {self._format_context_summary(raw_sessions, raw_events)}"
1218
- )
1219
- # Show no-track indicator
1220
- if w.no_track:
1221
- detail_line = f"{detail_line} [NT]"
1222
- details.append(detail_line, style="dim not bold")
1223
- return details
1224
-
1225
- @staticmethod
1226
- def _short_id(value: str) -> str:
1227
- value = str(value)
1228
- if len(value) > 20:
1229
- return value[:8] + "..." + value[-8:]
1230
- return value
1231
-
1232
- @staticmethod
1233
- def _safe_id_fragment(raw: str) -> str:
1234
- # Textual ids must match: [A-Za-z_][A-Za-z0-9_-]*
1235
- safe = re.sub(r"[^A-Za-z0-9_-]+", "-", raw).strip("-_")
1236
- if not safe:
1237
- return "w"
1238
- if safe[0].isdigit():
1239
- return f"w-{safe}"
1240
- return safe
1241
-
1242
- @staticmethod
1243
- def _command_in_directory(command: str, directory: str) -> str:
1244
- """Wrap a command to run in a specific directory."""
1245
- return f"cd {shlex.quote(directory)} && {command}"
1246
-
1247
- def _on_create_agent_result(self, result: tuple[str, str, bool, bool] | None) -> None:
1248
- """Handle the result from CreateAgentScreen modal."""
1249
- if result is None:
1250
- return
1251
-
1252
- agent_type, workspace, skip_permissions, no_track = result
1253
-
1254
- # Capture self reference for use in the deferred callback
1255
- panel = self
1256
-
1257
- # Use app.call_later to defer worker creation until after the modal is dismissed.
1258
- # This ensures the modal screen is fully closed before the worker starts,
1259
- # preventing UI update conflicts between modal closing and terminal panel refresh.
1260
- def start_worker() -> None:
1261
- panel.run_worker(
1262
- panel._create_agent(
1263
- agent_type, workspace, skip_permissions=skip_permissions, no_track=no_track
1264
- ),
1265
- group="terminal-panel-create",
1266
- exclusive=True,
1267
- )
1268
-
1269
- self.app.call_later(start_worker)
1270
-
1271
- async def _create_agent(
1272
- self, agent_type: str, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1273
- ) -> None:
1274
- """Create a new agent terminal based on the selected type and workspace."""
1275
- if agent_type == "claude":
1276
- await self._create_claude_terminal(workspace, skip_permissions=skip_permissions, no_track=no_track)
1277
- elif agent_type == "codex":
1278
- await self._create_codex_terminal(workspace, no_track=no_track)
1279
- elif agent_type == "opencode":
1280
- await self._create_opencode_terminal(workspace)
1281
- elif agent_type == "zsh":
1282
- await self._create_zsh_terminal(workspace)
1283
- # Schedule refresh in a separate worker to avoid blocking UI.
1284
- # The refresh involves slow synchronous operations (DB queries, file scans)
1285
- # that would otherwise freeze the dashboard.
1286
- self.run_worker(
1287
- self.refresh_data(),
1288
- group="terminal-panel-refresh",
1289
- exclusive=True,
1290
- )
1291
-
1292
- async def _create_claude_terminal(
1293
- self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1294
- ) -> None:
1295
- """Create a new Claude terminal."""
1296
- if self._is_native_mode():
1297
- await self._create_claude_terminal_native(workspace, skip_permissions=skip_permissions, no_track=no_track)
1298
- else:
1299
- await self._create_claude_terminal_tmux(workspace, skip_permissions=skip_permissions, no_track=no_track)
1300
-
1301
- async def _create_claude_terminal_native(
1302
- self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1303
- ) -> None:
1304
- """Create a new Claude terminal using native backend."""
1305
- backend = await self._ensure_native_backend()
1306
- if not backend:
1307
- self.app.notify(
1308
- "Native terminal backend not available",
1309
- title="Terminal",
1310
- severity="error",
1311
- )
1312
- return
1313
-
1314
- terminal_id = tmux_manager.new_terminal_id()
1315
- context_id = tmux_manager.new_context_id("cc")
1316
-
1317
- env = {
1318
- tmux_manager.ENV_TERMINAL_ID: terminal_id,
1319
- tmux_manager.ENV_TERMINAL_PROVIDER: "claude",
1320
- tmux_manager.ENV_CONTEXT_ID: context_id,
1321
- }
1322
- if no_track:
1323
- env["ALINE_NO_TRACK"] = "1"
1324
-
1325
- # Install hooks
1326
- self._install_claude_hooks(workspace)
1327
-
1328
- claude_cmd = "claude"
1329
- if skip_permissions:
1330
- claude_cmd = "claude --dangerously-skip-permissions"
1331
-
1332
- session_id = await backend.create_tab(
1333
- command=claude_cmd,
1334
- terminal_id=terminal_id,
1335
- name="Claude Code",
1336
- env=env,
1337
- cwd=workspace,
1338
- )
1339
-
1340
- if not session_id:
1341
- self.app.notify(
1342
- "Failed to open Claude terminal",
1343
- title="Terminal",
1344
- severity="error",
1345
- )
1346
-
1347
- async def _create_claude_terminal_tmux(
1348
- self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
1349
- ) -> None:
1350
- """Create a new Claude terminal using tmux backend."""
1351
- terminal_id = tmux_manager.new_terminal_id()
1352
- context_id = tmux_manager.new_context_id("cc")
1353
- env = {
1354
- tmux_manager.ENV_TERMINAL_ID: terminal_id,
1355
- tmux_manager.ENV_TERMINAL_PROVIDER: "claude",
1356
- tmux_manager.ENV_INNER_SOCKET: tmux_manager.INNER_SOCKET,
1357
- tmux_manager.ENV_INNER_SESSION: tmux_manager.INNER_SESSION,
1358
- tmux_manager.ENV_CONTEXT_ID: context_id,
1359
- }
1360
- if no_track:
1361
- env["ALINE_NO_TRACK"] = "1"
1362
-
1363
- # Install hooks
1364
- self._install_claude_hooks(workspace)
1365
-
1366
- claude_cmd = "claude"
1367
- if skip_permissions:
1368
- claude_cmd = "claude --dangerously-skip-permissions"
1369
- command = self._command_in_directory(
1370
- tmux_manager.zsh_run_and_keep_open(claude_cmd), workspace
1371
- )
1372
- created = tmux_manager.create_inner_window(
1373
- "cc",
1374
- tmux_manager.shell_command_with_env(command, env),
1375
- terminal_id=terminal_id,
1376
- provider="claude",
1377
- context_id=context_id,
1378
- no_track=no_track,
1379
- )
1380
- if not created:
1381
- self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
1382
-
1383
- def _install_claude_hooks(self, workspace: str) -> None:
1384
- """Install Claude hooks for a workspace."""
1385
- try:
1386
- from ...claude_hooks.stop_hook_installer import (
1387
- ensure_stop_hook_installed,
1388
- get_settings_path as get_stop_settings_path,
1389
- install_stop_hook,
1390
- )
1391
- from ...claude_hooks.user_prompt_submit_hook_installer import (
1392
- ensure_user_prompt_submit_hook_installed,
1393
- get_settings_path as get_submit_settings_path,
1394
- install_user_prompt_submit_hook,
1395
- )
1396
- from ...claude_hooks.permission_request_hook_installer import (
1397
- ensure_permission_request_hook_installed,
1398
- get_settings_path as get_permission_settings_path,
1399
- install_permission_request_hook,
1400
- )
1401
-
1402
- ok_global_stop = ensure_stop_hook_installed(quiet=True)
1403
- ok_global_submit = ensure_user_prompt_submit_hook_installed(quiet=True)
1404
- ok_global_permission = ensure_permission_request_hook_installed(quiet=True)
1405
-
1406
- project_root = Path(workspace)
1407
- ok_project_stop = install_stop_hook(
1408
- get_stop_settings_path(project_root), quiet=True
1409
- )
1410
- ok_project_submit = install_user_prompt_submit_hook(
1411
- get_submit_settings_path(project_root), quiet=True
1412
- )
1413
- ok_project_permission = install_permission_request_hook(
1414
- get_permission_settings_path(project_root), quiet=True
1415
- )
1416
-
1417
- all_hooks_ok = (
1418
- ok_global_stop
1419
- and ok_global_submit
1420
- and ok_global_permission
1421
- and ok_project_stop
1422
- and ok_project_submit
1423
- and ok_project_permission
1424
- )
1425
- if not all_hooks_ok:
1426
- self.app.notify(
1427
- "Claude hooks not fully installed; session id/title may not update",
1428
- title="Terminal",
1429
- severity="warning",
1430
- )
1431
- except Exception:
1432
- pass
1433
-
1434
- async def _create_codex_terminal(self, workspace: str, *, no_track: bool = False) -> None:
1435
- """Create a new Codex terminal."""
1436
- terminal_id = tmux_manager.new_terminal_id()
1437
- context_id = tmux_manager.new_context_id("cx")
1438
-
1439
- # Use per-terminal CODEX_HOME so sessions/config are isolated and binding is deterministic.
1440
- try:
1441
- from ...codex_home import prepare_codex_home
1442
-
1443
- codex_home = prepare_codex_home(terminal_id)
1444
- except Exception:
1445
- codex_home = None
1446
-
1447
- env = {
1448
- tmux_manager.ENV_TERMINAL_ID: terminal_id,
1449
- tmux_manager.ENV_TERMINAL_PROVIDER: "codex",
1450
- tmux_manager.ENV_CONTEXT_ID: context_id,
1451
- }
1452
- if codex_home is not None:
1453
- env["CODEX_HOME"] = str(codex_home)
1454
- if no_track:
1455
- env["ALINE_NO_TRACK"] = "1"
1456
-
1457
- # Persist agent early so the watcher can bind the Codex session file back to this terminal.
1458
- try:
1459
- from ...db import get_database
1460
-
1461
- db = get_database(read_only=False)
1462
- db.get_or_create_agent(
1463
- terminal_id,
1464
- provider="codex",
1465
- session_type="codex",
1466
- context_id=context_id,
1467
- cwd=workspace,
1468
- project_dir=workspace,
1469
- source="dashboard",
1470
- )
1471
- except Exception:
1472
- pass
1473
-
1474
- if self._is_native_mode():
1475
- backend = await self._ensure_native_backend()
1476
- if backend:
1477
- session_id = await backend.create_tab(
1478
- command="codex",
1479
- terminal_id=terminal_id,
1480
- name="Codex",
1481
- env=env,
1482
- cwd=workspace,
1483
- )
1484
- if not session_id:
1485
- self.app.notify(
1486
- "Failed to open Codex terminal", title="Terminal", severity="error"
1487
- )
1488
- return
1489
-
1490
- # Tmux fallback
1491
- command = self._command_in_directory(
1492
- tmux_manager.zsh_run_and_keep_open("codex"), workspace
1493
- )
1494
- created = tmux_manager.create_inner_window(
1495
- "codex",
1496
- tmux_manager.shell_command_with_env(command, env),
1497
- terminal_id=terminal_id,
1498
- provider="codex",
1499
- context_id=context_id,
1500
- no_track=no_track,
1501
- )
1502
- if not created:
1503
- self.app.notify("Failed to open Codex terminal", title="Terminal", severity="error")
1504
-
1505
- async def _create_opencode_terminal(self, workspace: str) -> None:
1506
- """Create a new Opencode terminal."""
1507
- if self._is_native_mode():
1508
- backend = await self._ensure_native_backend()
1509
- if backend:
1510
- terminal_id = tmux_manager.new_terminal_id()
1511
- session_id = await backend.create_tab(
1512
- command="opencode",
1513
- terminal_id=terminal_id,
1514
- name="Opencode",
1515
- cwd=workspace,
1516
- )
1517
- if not session_id:
1518
- self.app.notify(
1519
- "Failed to open Opencode terminal", title="Terminal", severity="error"
1520
- )
1521
- return
1522
-
1523
- # Tmux fallback
1524
- command = self._command_in_directory(
1525
- tmux_manager.zsh_run_and_keep_open("opencode"), workspace
1526
- )
1527
- created = tmux_manager.create_inner_window("opencode", command)
1528
- if not created:
1529
- self.app.notify("Failed to open Opencode terminal", title="Terminal", severity="error")
1530
-
1531
- async def _create_zsh_terminal(self, workspace: str) -> None:
1532
- """Create a new zsh terminal."""
1533
- t0 = time.time()
1534
- logger.info(f"[PERF] _create_zsh_terminal START")
1535
- if self._is_native_mode():
1536
- backend = await self._ensure_native_backend()
1537
- if backend:
1538
- terminal_id = tmux_manager.new_terminal_id()
1539
- session_id = await backend.create_tab(
1540
- command="zsh -l",
1541
- terminal_id=terminal_id,
1542
- name="zsh",
1543
- cwd=workspace,
1544
- )
1545
- if not session_id:
1546
- self.app.notify(
1547
- "Failed to open zsh terminal", title="Terminal", severity="error"
1548
- )
1549
- logger.info(f"[PERF] _create_zsh_terminal native END: {time.time() - t0:.3f}s")
1550
- return
1551
-
1552
- # Tmux fallback
1553
- t1 = time.time()
1554
- command = self._command_in_directory("zsh", workspace)
1555
- logger.info(f"[PERF] _create_zsh_terminal command ready: {time.time() - t1:.3f}s")
1556
- t2 = time.time()
1557
- created = tmux_manager.create_inner_window("zsh", command)
1558
- logger.info(f"[PERF] _create_zsh_terminal create_inner_window: {time.time() - t2:.3f}s")
1559
- if not created:
1560
- self.app.notify("Failed to open zsh terminal", title="Terminal", severity="error")
1561
- logger.info(f"[PERF] _create_zsh_terminal TOTAL: {time.time() - t0:.3f}s")
1562
-
1563
- async def on_button_pressed(self, event: Button.Pressed) -> None:
1564
- button_id = event.button.id or ""
1565
-
1566
- if not self.supported():
1567
- self.app.notify(
1568
- self._support_message(),
1569
- title="Terminal",
1570
- severity="warning",
1571
- )
1572
- return
1573
-
1574
- if button_id == "new-agent":
1575
- from ..screens import CreateAgentScreen
1576
-
1577
- self.app.push_screen(CreateAgentScreen(), self._on_create_agent_result)
1578
- return
1579
-
1580
- if button_id.startswith("switch-"):
1581
- await self._handle_switch(event.button.name or "")
1582
- return
1583
-
1584
- if button_id.startswith("toggle-"):
1585
- window_id = event.button.name or ""
1586
- if not window_id:
1587
- return
1588
- if self._expanded_window_id == window_id:
1589
- self._expanded_window_id = None
1590
- else:
1591
- self._expanded_window_id = window_id
1592
- await self.refresh_data()
1593
- return
1594
-
1595
- if button_id.startswith("ctxsess-"):
1596
- session_id = (event.button.name or "").strip()
1597
- if not session_id:
1598
- return
1599
- try:
1600
- from ..screens import SessionDetailScreen
1601
-
1602
- self.app.push_screen(SessionDetailScreen(session_id))
1603
- except Exception:
1604
- pass
1605
- return
1606
-
1607
- if button_id.startswith("close-"):
1608
- await self._handle_close(event.button.name or "")
1609
- return
1610
-
1611
- async def _handle_switch(self, window_id: str) -> None:
1612
- """Handle switching to a terminal."""
1613
- if not window_id:
1614
- return
1615
-
1616
- if self._is_native_mode():
1617
- backend = await self._ensure_native_backend()
1618
- if backend:
1619
- success = await backend.focus_tab(window_id, steal_focus=True)
1620
- if not success:
1621
- self.app.notify(
1622
- "Failed to switch terminal", title="Terminal", severity="error"
1623
- )
1624
- else:
1625
- if not tmux_manager.select_inner_window(window_id):
1626
- self.app.notify("Failed to switch terminal", title="Terminal", severity="error")
1627
- else:
1628
- # Move cursor focus to the right pane (terminal area)
1629
- tmux_manager.focus_right_pane()
1630
- # Clear attention when user clicks on terminal
1631
- tmux_manager.clear_attention(window_id)
1632
-
1633
- self._expanded_window_id = None
1634
- await self.refresh_data()
1635
-
1636
- async def _handle_close(self, window_id: str) -> None:
1637
- """Handle closing a terminal."""
1638
- if not window_id:
1639
- return
1640
-
1641
- if self._is_native_mode():
1642
- backend = await self._ensure_native_backend()
1643
- if backend:
1644
- success = await backend.close_tab(window_id)
1645
- if not success:
1646
- self.app.notify(
1647
- "Failed to close terminal", title="Terminal", severity="error"
1648
- )
1649
- else:
1650
- if not tmux_manager.kill_inner_window(window_id):
1651
- self.app.notify("Failed to close terminal", title="Terminal", severity="error")
1652
-
1653
- await self.refresh_data()