aline-ai 0.6.3__py3-none-any.whl → 0.6.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/METADATA +1 -1
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/RECORD +26 -23
- realign/__init__.py +1 -1
- realign/adapters/codex.py +14 -9
- realign/cli.py +42 -235
- realign/codex_detector.py +72 -32
- realign/codex_home.py +85 -0
- realign/codex_terminal_linker.py +172 -0
- realign/commands/__init__.py +2 -2
- realign/commands/add.py +89 -9
- realign/commands/doctor.py +497 -0
- realign/commands/init.py +66 -4
- realign/commands/watcher.py +2 -1
- realign/config.py +10 -1
- realign/dashboard/app.py +2 -149
- realign/dashboard/tmux_manager.py +171 -5
- realign/dashboard/widgets/config_panel.py +91 -11
- realign/dashboard/widgets/sessions_table.py +1 -1
- realign/dashboard/widgets/terminal_panel.py +400 -35
- realign/db/sqlite_db.py +76 -0
- realign/hooks.py +6 -128
- realign/watcher_core.py +50 -0
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.6.3.dist-info → aline_ai-0.6.5.dist-info}/top_level.txt +0 -0
|
@@ -15,7 +15,9 @@ import asyncio
|
|
|
15
15
|
import os
|
|
16
16
|
import re
|
|
17
17
|
import shlex
|
|
18
|
+
import time
|
|
18
19
|
import traceback
|
|
20
|
+
from datetime import datetime, timedelta, timezone
|
|
19
21
|
from pathlib import Path
|
|
20
22
|
from typing import Callable, Union
|
|
21
23
|
|
|
@@ -385,13 +387,175 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
385
387
|
# Native terminal: check provider
|
|
386
388
|
return w.provider == "claude"
|
|
387
389
|
|
|
388
|
-
# tmux: check window name and tags
|
|
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
|
|
390
399
|
if re.fullmatch(r"cc(?:-\d+)?", window_name or ""):
|
|
391
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
|
|
392
449
|
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
395
559
|
|
|
396
560
|
def __init__(self, use_native_terminal: bool | None = None) -> None:
|
|
397
561
|
"""Initialize the terminal panel.
|
|
@@ -420,6 +584,9 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
420
584
|
self._native_backend: TerminalBackend | None = None
|
|
421
585
|
self._native_backend_checked = False
|
|
422
586
|
|
|
587
|
+
# Best-effort Codex session binding without requiring the watcher process.
|
|
588
|
+
self._codex_link_last_attempt: dict[str, float] = {}
|
|
589
|
+
|
|
423
590
|
def compose(self) -> ComposeResult:
|
|
424
591
|
logger.debug("TerminalPanel.compose() started")
|
|
425
592
|
try:
|
|
@@ -501,10 +668,63 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
501
668
|
|
|
502
669
|
async def refresh_data(self) -> None:
|
|
503
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()
|
|
504
677
|
if self._is_native_mode():
|
|
505
678
|
await self._refresh_native_data()
|
|
506
679
|
else:
|
|
507
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}")
|
|
508
728
|
|
|
509
729
|
async def _refresh_native_data(self) -> None:
|
|
510
730
|
"""Refresh data using native terminal backend."""
|
|
@@ -525,24 +745,36 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
525
745
|
logger.error(f"Failed to list native terminals: {e}")
|
|
526
746
|
return
|
|
527
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
|
+
|
|
528
756
|
active_window_id = next(
|
|
529
757
|
(w.session_id for w in windows if w.active), None
|
|
530
758
|
)
|
|
531
759
|
if self._expanded_window_id and self._expanded_window_id != active_window_id:
|
|
532
760
|
self._expanded_window_id = None
|
|
533
761
|
|
|
534
|
-
#
|
|
762
|
+
# Titles (best-effort; native terminals only expose Claude session ids today)
|
|
535
763
|
claude_ids = [
|
|
536
|
-
w.claude_session_id
|
|
764
|
+
w.claude_session_id
|
|
765
|
+
for w in windows
|
|
537
766
|
if self._is_claude_window(w) and w.claude_session_id
|
|
538
767
|
]
|
|
539
768
|
titles = self._fetch_claude_session_titles(claude_ids)
|
|
540
769
|
|
|
770
|
+
# Yield to event loop after DB query
|
|
771
|
+
await asyncio.sleep(0)
|
|
772
|
+
|
|
541
773
|
# Get context info
|
|
542
774
|
context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
|
|
543
775
|
all_context_session_ids: set[str] = set()
|
|
544
776
|
for w in windows:
|
|
545
|
-
if not self.
|
|
777
|
+
if not self._supports_context(w) or not w.context_id:
|
|
546
778
|
continue
|
|
547
779
|
session_ids, session_count, event_count = self._get_loaded_context_info(
|
|
548
780
|
w.context_id
|
|
@@ -566,6 +798,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
566
798
|
|
|
567
799
|
async def _refresh_tmux_data(self) -> None:
|
|
568
800
|
"""Refresh data using tmux backend."""
|
|
801
|
+
t0 = time.time()
|
|
569
802
|
try:
|
|
570
803
|
supported = self.supported()
|
|
571
804
|
except Exception:
|
|
@@ -578,20 +811,34 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
578
811
|
windows = tmux_manager.list_inner_windows()
|
|
579
812
|
except Exception:
|
|
580
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.
|
|
581
824
|
|
|
582
825
|
active_window_id = next((w.window_id for w in windows if w.active), None)
|
|
583
826
|
if self._expanded_window_id and self._expanded_window_id != active_window_id:
|
|
584
827
|
self._expanded_window_id = None
|
|
585
828
|
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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)
|
|
590
836
|
|
|
837
|
+
t2 = time.time()
|
|
591
838
|
context_info_by_context_id: dict[str, tuple[list[str], int, int]] = {}
|
|
592
839
|
all_context_session_ids: set[str] = set()
|
|
593
840
|
for w in windows:
|
|
594
|
-
if not self.
|
|
841
|
+
if not self._supports_context(w) or not w.context_id:
|
|
595
842
|
continue
|
|
596
843
|
session_ids, session_count, event_count = self._get_loaded_context_info(
|
|
597
844
|
w.context_id
|
|
@@ -604,16 +851,27 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
604
851
|
event_count,
|
|
605
852
|
)
|
|
606
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")
|
|
607
857
|
|
|
858
|
+
t3 = time.time()
|
|
608
859
|
if all_context_session_ids:
|
|
609
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")
|
|
610
862
|
|
|
863
|
+
t4 = time.time()
|
|
611
864
|
try:
|
|
612
865
|
await self._render_terminals_tmux(windows, titles, context_info_by_context_id)
|
|
613
866
|
except Exception:
|
|
614
867
|
return
|
|
868
|
+
logger.debug(f"[PERF] render_terminals_tmux: {time.time() - t4:.3f}s")
|
|
615
869
|
|
|
616
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]:
|
|
617
875
|
if not session_ids:
|
|
618
876
|
return {}
|
|
619
877
|
try:
|
|
@@ -701,7 +959,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
701
959
|
loaded_ids: list[str] = []
|
|
702
960
|
raw_sessions = 0
|
|
703
961
|
raw_events = 0
|
|
704
|
-
if self.
|
|
962
|
+
if self._supports_context(w) and w.context_id:
|
|
705
963
|
loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
|
|
706
964
|
w.context_id, ([], 0, 0)
|
|
707
965
|
)
|
|
@@ -716,9 +974,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
716
974
|
)
|
|
717
975
|
)
|
|
718
976
|
|
|
719
|
-
can_toggle_ctx = bool(
|
|
720
|
-
self._is_claude_window(w) and w.context_id and (raw_sessions or raw_events)
|
|
721
|
-
)
|
|
977
|
+
can_toggle_ctx = bool(self._supports_context(w) and w.context_id and (raw_sessions or raw_events))
|
|
722
978
|
expanded = bool(w.active and w.session_id == self._expanded_window_id)
|
|
723
979
|
if w.active and can_toggle_ctx:
|
|
724
980
|
await row.mount(
|
|
@@ -741,7 +997,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
741
997
|
)
|
|
742
998
|
)
|
|
743
999
|
|
|
744
|
-
if w.active and self.
|
|
1000
|
+
if w.active and self._supports_context(w) and w.context_id and expanded:
|
|
745
1001
|
ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
|
|
746
1002
|
await container.mount(ctx)
|
|
747
1003
|
if loaded_ids:
|
|
@@ -790,7 +1046,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
790
1046
|
loaded_ids: list[str] = []
|
|
791
1047
|
raw_sessions = 0
|
|
792
1048
|
raw_events = 0
|
|
793
|
-
if self.
|
|
1049
|
+
if self._supports_context(w) and w.context_id:
|
|
794
1050
|
loaded_ids, raw_sessions, raw_events = context_info_by_context_id.get(
|
|
795
1051
|
w.context_id, ([], 0, 0)
|
|
796
1052
|
)
|
|
@@ -803,9 +1059,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
803
1059
|
classes=switch_classes,
|
|
804
1060
|
)
|
|
805
1061
|
)
|
|
806
|
-
can_toggle_ctx = bool(
|
|
807
|
-
self._is_claude_window(w) and w.context_id and (raw_sessions or raw_events)
|
|
808
|
-
)
|
|
1062
|
+
can_toggle_ctx = bool(self._supports_context(w) and w.context_id and (raw_sessions or raw_events))
|
|
809
1063
|
expanded = bool(w.active and w.window_id == self._expanded_window_id)
|
|
810
1064
|
if w.active and can_toggle_ctx:
|
|
811
1065
|
await row.mount(
|
|
@@ -827,7 +1081,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
827
1081
|
)
|
|
828
1082
|
)
|
|
829
1083
|
|
|
830
|
-
if w.active and self.
|
|
1084
|
+
if w.active and self._supports_context(w) and w.context_id and expanded:
|
|
831
1085
|
ctx = VerticalScroll(id=f"ctx-{safe}", classes="context-sessions")
|
|
832
1086
|
await container.mount(ctx)
|
|
833
1087
|
if loaded_ids:
|
|
@@ -868,9 +1122,26 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
868
1122
|
raw_events: int = 0,
|
|
869
1123
|
) -> str | Text:
|
|
870
1124
|
"""Generate label for native terminal window."""
|
|
871
|
-
if not self.
|
|
1125
|
+
if not self._supports_context(w):
|
|
872
1126
|
return Text(w.name, no_wrap=True, overflow="ellipsis")
|
|
873
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
|
+
|
|
874
1145
|
title = titles.get(w.claude_session_id or "", "").strip() if w.claude_session_id else ""
|
|
875
1146
|
header = title or ("Claude" if w.claude_session_id else "New Claude")
|
|
876
1147
|
|
|
@@ -902,9 +1173,32 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
902
1173
|
raw_events: int = 0,
|
|
903
1174
|
) -> str | Text:
|
|
904
1175
|
"""Generate label for tmux window."""
|
|
905
|
-
if not self.
|
|
1176
|
+
if not self._supports_context(w):
|
|
906
1177
|
return Text(w.window_name, no_wrap=True, overflow="ellipsis")
|
|
907
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
|
+
|
|
908
1202
|
title = titles.get(w.session_id or "", "").strip() if w.session_id else ""
|
|
909
1203
|
header = title or ("Claude" if w.session_id else "New Claude")
|
|
910
1204
|
|
|
@@ -956,13 +1250,23 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
956
1250
|
return
|
|
957
1251
|
|
|
958
1252
|
agent_type, workspace, skip_permissions, no_track = result
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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)
|
|
966
1270
|
|
|
967
1271
|
async def _create_agent(
|
|
968
1272
|
self, agent_type: str, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
|
|
@@ -971,12 +1275,19 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
971
1275
|
if agent_type == "claude":
|
|
972
1276
|
await self._create_claude_terminal(workspace, skip_permissions=skip_permissions, no_track=no_track)
|
|
973
1277
|
elif agent_type == "codex":
|
|
974
|
-
await self._create_codex_terminal(workspace)
|
|
1278
|
+
await self._create_codex_terminal(workspace, no_track=no_track)
|
|
975
1279
|
elif agent_type == "opencode":
|
|
976
1280
|
await self._create_opencode_terminal(workspace)
|
|
977
1281
|
elif agent_type == "zsh":
|
|
978
1282
|
await self._create_zsh_terminal(workspace)
|
|
979
|
-
|
|
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
|
+
)
|
|
980
1291
|
|
|
981
1292
|
async def _create_claude_terminal(
|
|
982
1293
|
self, workspace: str, *, skip_permissions: bool = False, no_track: bool = False
|
|
@@ -1064,6 +1375,7 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
1064
1375
|
terminal_id=terminal_id,
|
|
1065
1376
|
provider="claude",
|
|
1066
1377
|
context_id=context_id,
|
|
1378
|
+
no_track=no_track,
|
|
1067
1379
|
)
|
|
1068
1380
|
if not created:
|
|
1069
1381
|
self.app.notify("Failed to open Claude terminal", title="Terminal", severity="error")
|
|
@@ -1119,16 +1431,54 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
1119
1431
|
except Exception:
|
|
1120
1432
|
pass
|
|
1121
1433
|
|
|
1122
|
-
async def _create_codex_terminal(self, workspace: str) -> None:
|
|
1434
|
+
async def _create_codex_terminal(self, workspace: str, *, no_track: bool = False) -> None:
|
|
1123
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
|
+
|
|
1124
1474
|
if self._is_native_mode():
|
|
1125
1475
|
backend = await self._ensure_native_backend()
|
|
1126
1476
|
if backend:
|
|
1127
|
-
terminal_id = tmux_manager.new_terminal_id()
|
|
1128
1477
|
session_id = await backend.create_tab(
|
|
1129
1478
|
command="codex",
|
|
1130
1479
|
terminal_id=terminal_id,
|
|
1131
1480
|
name="Codex",
|
|
1481
|
+
env=env,
|
|
1132
1482
|
cwd=workspace,
|
|
1133
1483
|
)
|
|
1134
1484
|
if not session_id:
|
|
@@ -1141,7 +1491,14 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
1141
1491
|
command = self._command_in_directory(
|
|
1142
1492
|
tmux_manager.zsh_run_and_keep_open("codex"), workspace
|
|
1143
1493
|
)
|
|
1144
|
-
created = tmux_manager.create_inner_window(
|
|
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
|
+
)
|
|
1145
1502
|
if not created:
|
|
1146
1503
|
self.app.notify("Failed to open Codex terminal", title="Terminal", severity="error")
|
|
1147
1504
|
|
|
@@ -1173,6 +1530,8 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
1173
1530
|
|
|
1174
1531
|
async def _create_zsh_terminal(self, workspace: str) -> None:
|
|
1175
1532
|
"""Create a new zsh terminal."""
|
|
1533
|
+
t0 = time.time()
|
|
1534
|
+
logger.info(f"[PERF] _create_zsh_terminal START")
|
|
1176
1535
|
if self._is_native_mode():
|
|
1177
1536
|
backend = await self._ensure_native_backend()
|
|
1178
1537
|
if backend:
|
|
@@ -1187,13 +1546,19 @@ class TerminalPanel(Container, can_focus=True):
|
|
|
1187
1546
|
self.app.notify(
|
|
1188
1547
|
"Failed to open zsh terminal", title="Terminal", severity="error"
|
|
1189
1548
|
)
|
|
1549
|
+
logger.info(f"[PERF] _create_zsh_terminal native END: {time.time() - t0:.3f}s")
|
|
1190
1550
|
return
|
|
1191
1551
|
|
|
1192
1552
|
# Tmux fallback
|
|
1553
|
+
t1 = time.time()
|
|
1193
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()
|
|
1194
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")
|
|
1195
1559
|
if not created:
|
|
1196
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")
|
|
1197
1562
|
|
|
1198
1563
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
1199
1564
|
button_id = event.button.id or ""
|