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.
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/METADATA +1 -1
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/RECORD +41 -34
- realign/__init__.py +1 -1
- realign/agent_names.py +79 -0
- realign/claude_hooks/stop_hook.py +3 -0
- realign/claude_hooks/terminal_state.py +43 -1
- realign/claude_hooks/user_prompt_submit_hook.py +3 -0
- realign/cli.py +62 -0
- realign/codex_detector.py +18 -3
- realign/codex_home.py +65 -16
- realign/codex_terminal_linker.py +18 -7
- realign/commands/agent.py +109 -0
- realign/commands/doctor.py +74 -1
- realign/commands/export_shares.py +448 -0
- realign/commands/import_shares.py +203 -1
- realign/commands/search.py +58 -29
- realign/commands/sync_agent.py +347 -0
- realign/dashboard/app.py +9 -9
- realign/dashboard/clipboard.py +54 -0
- realign/dashboard/screens/__init__.py +4 -0
- realign/dashboard/screens/agent_detail.py +333 -0
- realign/dashboard/screens/create_agent_info.py +244 -0
- realign/dashboard/screens/event_detail.py +6 -27
- realign/dashboard/styles/dashboard.tcss +22 -28
- realign/dashboard/tmux_manager.py +36 -10
- realign/dashboard/widgets/__init__.py +2 -2
- realign/dashboard/widgets/agents_panel.py +1248 -0
- realign/dashboard/widgets/events_table.py +4 -27
- realign/dashboard/widgets/sessions_table.py +4 -27
- realign/db/base.py +69 -0
- realign/db/locks.py +4 -0
- realign/db/schema.py +111 -2
- realign/db/sqlite_db.py +360 -2
- realign/events/agent_summarizer.py +157 -0
- realign/events/session_summarizer.py +25 -0
- realign/watcher_core.py +193 -5
- realign/worker_core.py +59 -1
- realign/dashboard/widgets/terminal_panel.py +0 -1653
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/WHEEL +0 -0
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/licenses/LICENSE +0 -0
- {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()
|