aline-ai 0.7.3__py3-none-any.whl → 0.7.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.7.3.dist-info → aline_ai-0.7.5.dist-info}/METADATA +1 -1
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.5.dist-info}/RECORD +32 -27
- realign/__init__.py +1 -1
- realign/adapters/codex.py +30 -2
- realign/claude_hooks/stop_hook.py +176 -21
- realign/codex_home.py +71 -0
- realign/codex_hooks/__init__.py +16 -0
- realign/codex_hooks/notify_hook.py +511 -0
- realign/codex_hooks/notify_hook_installer.py +247 -0
- realign/commands/doctor.py +125 -0
- realign/commands/import_shares.py +30 -10
- realign/commands/init.py +16 -0
- realign/commands/sync_agent.py +230 -52
- realign/commands/upgrade.py +117 -0
- realign/commit_pipeline.py +1024 -0
- realign/config.py +3 -11
- realign/dashboard/app.py +150 -0
- realign/dashboard/diagnostics.py +274 -0
- realign/dashboard/screens/create_agent.py +2 -1
- realign/dashboard/screens/create_agent_info.py +40 -77
- realign/dashboard/tmux_manager.py +354 -20
- realign/dashboard/widgets/agents_panel.py +817 -202
- realign/dashboard/widgets/config_panel.py +52 -121
- realign/dashboard/widgets/header.py +1 -1
- realign/db/sqlite_db.py +59 -1
- realign/logging_config.py +51 -6
- realign/watcher_core.py +742 -393
- realign/worker_core.py +206 -15
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.5.dist-info}/WHEEL +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.5.dist-info}/entry_points.txt +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.5.dist-info}/licenses/LICENSE +0 -0
- {aline_ai-0.7.3.dist-info → aline_ai-0.7.5.dist-info}/top_level.txt +0 -0
|
@@ -7,13 +7,14 @@ import json as _json
|
|
|
7
7
|
import re
|
|
8
8
|
import shlex
|
|
9
9
|
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Optional
|
|
12
13
|
|
|
13
14
|
from textual import events
|
|
14
15
|
from textual.app import ComposeResult
|
|
15
16
|
from textual.containers import Container, Horizontal, Vertical
|
|
16
|
-
from textual.widgets import Button, Static
|
|
17
|
+
from textual.widgets import Button, Rule, Static
|
|
17
18
|
from textual.message import Message
|
|
18
19
|
from textual.worker import Worker, WorkerState
|
|
19
20
|
from rich.text import Text
|
|
@@ -25,6 +26,54 @@ from ..clipboard import copy_text
|
|
|
25
26
|
logger = setup_logger("realign.dashboard.widgets.agents_panel", "dashboard.log")
|
|
26
27
|
|
|
27
28
|
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class _ProcessSnapshot:
|
|
31
|
+
created_at_monotonic: float
|
|
32
|
+
tty_key: str
|
|
33
|
+
children: dict[int, list[int]]
|
|
34
|
+
comms: dict[int, str]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TruncButton(Button):
|
|
38
|
+
"""Button that truncates its label with '...' based on actual widget width."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, full_text: str, **kwargs) -> None:
|
|
41
|
+
super().__init__(full_text, **kwargs)
|
|
42
|
+
self._full_text = full_text
|
|
43
|
+
|
|
44
|
+
def render(self) -> Text:
|
|
45
|
+
width = self.size.width
|
|
46
|
+
gutter = self.styles.gutter
|
|
47
|
+
avail = width - gutter.width
|
|
48
|
+
text = self._full_text
|
|
49
|
+
if avail > 3 and len(text) > avail:
|
|
50
|
+
return Text(text[: avail - 3] + "...")
|
|
51
|
+
return Text(text)
|
|
52
|
+
|
|
53
|
+
class SpinnerPrefixButton(Button):
|
|
54
|
+
"""Button label with an animated spinner prefix, truncated to widget width."""
|
|
55
|
+
|
|
56
|
+
_FRAMES = ("⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏")
|
|
57
|
+
|
|
58
|
+
def __init__(self, *, prefix: str, label: str, **kwargs) -> None:
|
|
59
|
+
self._prefix = prefix
|
|
60
|
+
self._label = label
|
|
61
|
+
super().__init__(f"{prefix}{label}", **kwargs)
|
|
62
|
+
|
|
63
|
+
def render(self) -> Text:
|
|
64
|
+
width = self.size.width
|
|
65
|
+
gutter = self.styles.gutter
|
|
66
|
+
avail = width - gutter.width
|
|
67
|
+
|
|
68
|
+
idx = int(time.monotonic() * 8) % len(self._FRAMES)
|
|
69
|
+
spinner = self._FRAMES[idx]
|
|
70
|
+
text = f"{self._prefix}{spinner} {self._label}"
|
|
71
|
+
|
|
72
|
+
if avail > 3 and len(text) > avail:
|
|
73
|
+
return Text(text[: avail - 3] + "...")
|
|
74
|
+
return Text(text)
|
|
75
|
+
|
|
76
|
+
|
|
28
77
|
class AgentNameButton(Button):
|
|
29
78
|
"""Button that emits a message on double-click."""
|
|
30
79
|
|
|
@@ -40,9 +89,178 @@ class AgentNameButton(Button):
|
|
|
40
89
|
self.post_message(self.DoubleClicked(self, self.name or ""))
|
|
41
90
|
|
|
42
91
|
|
|
92
|
+
class _TerminalRowWidget(Horizontal):
|
|
93
|
+
def __init__(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
terminal_id: str,
|
|
97
|
+
term_safe_id: str,
|
|
98
|
+
prefix: str,
|
|
99
|
+
title: str,
|
|
100
|
+
running_agent: str | None,
|
|
101
|
+
pane_current_command: str | None,
|
|
102
|
+
is_active: bool,
|
|
103
|
+
) -> None:
|
|
104
|
+
super().__init__(classes="terminal-row")
|
|
105
|
+
|
|
106
|
+
switch_classes = "terminal-switch active-terminal" if is_active else "terminal-switch"
|
|
107
|
+
|
|
108
|
+
pane_cmd = (pane_current_command or "").strip().lower()
|
|
109
|
+
title_is_relevant = bool(title) and (
|
|
110
|
+
bool(running_agent) or pane_cmd in {"claude", "codex", "opencode"}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if title_is_relevant:
|
|
114
|
+
label = f"{prefix}{title}"
|
|
115
|
+
switch_btn: Button = TruncButton(
|
|
116
|
+
label,
|
|
117
|
+
id=f"switch-{term_safe_id}",
|
|
118
|
+
name=terminal_id,
|
|
119
|
+
classes=switch_classes,
|
|
120
|
+
)
|
|
121
|
+
switch_btn.tooltip = title
|
|
122
|
+
elif running_agent:
|
|
123
|
+
provider = (running_agent or "").strip().lower()
|
|
124
|
+
provider_label = (running_agent or "").strip().capitalize()
|
|
125
|
+
if provider in {"codex", "claude"}:
|
|
126
|
+
switch_btn = SpinnerPrefixButton(
|
|
127
|
+
prefix=prefix,
|
|
128
|
+
label=provider_label,
|
|
129
|
+
id=f"switch-{term_safe_id}",
|
|
130
|
+
name=terminal_id,
|
|
131
|
+
classes=f"{switch_classes} spinner",
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
switch_btn = TruncButton(
|
|
135
|
+
f"{prefix}{provider_label}",
|
|
136
|
+
id=f"switch-{term_safe_id}",
|
|
137
|
+
name=terminal_id,
|
|
138
|
+
classes=switch_classes,
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
display = (pane_current_command or "").strip() or "Terminal"
|
|
142
|
+
switch_btn = TruncButton(
|
|
143
|
+
f"{prefix}{display}",
|
|
144
|
+
id=f"switch-{term_safe_id}",
|
|
145
|
+
name=terminal_id,
|
|
146
|
+
classes=switch_classes,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
self._switch_btn = switch_btn
|
|
150
|
+
self._close_btn = Button(
|
|
151
|
+
"✕",
|
|
152
|
+
id=f"close-{term_safe_id}",
|
|
153
|
+
name=terminal_id,
|
|
154
|
+
variant="error",
|
|
155
|
+
classes="terminal-close",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def compose(self) -> ComposeResult:
|
|
159
|
+
yield self._switch_btn
|
|
160
|
+
yield self._close_btn
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _TerminalConnectorWidget(Horizontal):
|
|
164
|
+
def __init__(self) -> None:
|
|
165
|
+
super().__init__(classes="terminal-row")
|
|
166
|
+
|
|
167
|
+
def compose(self) -> ComposeResult:
|
|
168
|
+
yield Button("│", classes="terminal-switch")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class _AgentBlockWidget(Container):
|
|
172
|
+
def __init__(self, *, agent: dict, active_terminal_id: str | None) -> None:
|
|
173
|
+
super().__init__(classes="agent-block")
|
|
174
|
+
self._agent = agent
|
|
175
|
+
self._active_terminal_id = active_terminal_id
|
|
176
|
+
|
|
177
|
+
def compose(self) -> ComposeResult:
|
|
178
|
+
agent = self._agent
|
|
179
|
+
safe_id = AgentsPanel._safe_id(agent["id"])
|
|
180
|
+
|
|
181
|
+
rule = Rule(line_style="solid")
|
|
182
|
+
rule.styles.color = "black"
|
|
183
|
+
yield rule
|
|
184
|
+
|
|
185
|
+
with Horizontal(classes="agent-row"):
|
|
186
|
+
name_label = Text(f"▸ {agent['name']}", style="bold")
|
|
187
|
+
agent_btn = AgentNameButton(
|
|
188
|
+
name_label,
|
|
189
|
+
id=f"agent-{safe_id}",
|
|
190
|
+
name=agent["id"],
|
|
191
|
+
classes="agent-name",
|
|
192
|
+
)
|
|
193
|
+
if agent.get("description"):
|
|
194
|
+
agent_btn.tooltip = agent["description"]
|
|
195
|
+
yield agent_btn
|
|
196
|
+
|
|
197
|
+
if agent.get("share_url"):
|
|
198
|
+
yield Button(
|
|
199
|
+
"Sync",
|
|
200
|
+
id=f"sync-{safe_id}",
|
|
201
|
+
name=agent["id"],
|
|
202
|
+
classes="agent-share",
|
|
203
|
+
)
|
|
204
|
+
yield Button(
|
|
205
|
+
"Link",
|
|
206
|
+
id=f"link-{safe_id}",
|
|
207
|
+
name=agent["id"],
|
|
208
|
+
classes="agent-share",
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
yield Button(
|
|
212
|
+
"Share",
|
|
213
|
+
id=f"share-{safe_id}",
|
|
214
|
+
name=agent["id"],
|
|
215
|
+
classes="agent-share",
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
yield Button(
|
|
219
|
+
"+",
|
|
220
|
+
id=f"create-term-{safe_id}",
|
|
221
|
+
name=agent["id"],
|
|
222
|
+
classes="agent-create",
|
|
223
|
+
)
|
|
224
|
+
yield Button(
|
|
225
|
+
"✕",
|
|
226
|
+
id=f"delete-{safe_id}",
|
|
227
|
+
name=agent["id"],
|
|
228
|
+
variant="error",
|
|
229
|
+
classes="agent-delete",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
terminals = agent.get("terminals") or []
|
|
233
|
+
if not terminals:
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
with Vertical(classes="terminal-list"):
|
|
237
|
+
for term_idx, term in enumerate(terminals):
|
|
238
|
+
is_last_term = term_idx == len(terminals) - 1
|
|
239
|
+
prefix = "└ " if is_last_term else "├ "
|
|
240
|
+
|
|
241
|
+
terminal_id = term["terminal_id"]
|
|
242
|
+
term_safe_id = AgentsPanel._safe_id(terminal_id)
|
|
243
|
+
is_active = terminal_id == self._active_terminal_id
|
|
244
|
+
|
|
245
|
+
yield _TerminalRowWidget(
|
|
246
|
+
terminal_id=terminal_id,
|
|
247
|
+
term_safe_id=term_safe_id,
|
|
248
|
+
prefix=prefix,
|
|
249
|
+
title=term.get("title", "") or "",
|
|
250
|
+
running_agent=term.get("running_agent"),
|
|
251
|
+
pane_current_command=term.get("pane_current_command"),
|
|
252
|
+
is_active=is_active,
|
|
253
|
+
)
|
|
254
|
+
if not is_last_term:
|
|
255
|
+
yield _TerminalConnectorWidget()
|
|
256
|
+
|
|
257
|
+
|
|
43
258
|
class AgentsPanel(Container, can_focus=True):
|
|
44
259
|
"""Panel displaying agent profiles with their associated terminals."""
|
|
45
260
|
|
|
261
|
+
REFRESH_INTERVAL_SECONDS = 2.0
|
|
262
|
+
SPINNER_INTERVAL_SECONDS = 0.15
|
|
263
|
+
|
|
46
264
|
DEFAULT_CSS = """
|
|
47
265
|
AgentsPanel {
|
|
48
266
|
height: 100%;
|
|
@@ -56,10 +274,11 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
56
274
|
|
|
57
275
|
AgentsPanel .summary {
|
|
58
276
|
height: auto;
|
|
59
|
-
margin: 0
|
|
277
|
+
margin: 0;
|
|
60
278
|
padding: 0;
|
|
61
279
|
background: transparent;
|
|
62
280
|
border: none;
|
|
281
|
+
align: right middle;
|
|
63
282
|
}
|
|
64
283
|
|
|
65
284
|
AgentsPanel Button {
|
|
@@ -86,15 +305,22 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
86
305
|
background: transparent;
|
|
87
306
|
}
|
|
88
307
|
|
|
308
|
+
AgentsPanel Rule {
|
|
309
|
+
margin: 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
89
312
|
AgentsPanel .agent-row {
|
|
313
|
+
height: 2;
|
|
314
|
+
margin: 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
AgentsPanel .agent-block {
|
|
90
318
|
height: auto;
|
|
91
|
-
min-height: 2;
|
|
92
|
-
margin: 0 0 0 0;
|
|
93
319
|
}
|
|
94
320
|
|
|
95
321
|
AgentsPanel .agent-row Button.agent-name {
|
|
96
322
|
width: 1fr;
|
|
97
|
-
height:
|
|
323
|
+
height: 1;
|
|
98
324
|
margin: 0;
|
|
99
325
|
padding: 0 1;
|
|
100
326
|
text-align: left;
|
|
@@ -102,28 +328,28 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
102
328
|
}
|
|
103
329
|
|
|
104
330
|
AgentsPanel .agent-row Button.agent-create {
|
|
105
|
-
width:
|
|
106
|
-
min-width:
|
|
107
|
-
height:
|
|
108
|
-
margin-left:
|
|
109
|
-
padding: 0
|
|
331
|
+
width: 3;
|
|
332
|
+
min-width: 3;
|
|
333
|
+
height: 1;
|
|
334
|
+
margin-left: 0;
|
|
335
|
+
padding: 0;
|
|
110
336
|
content-align: center middle;
|
|
111
337
|
}
|
|
112
338
|
|
|
113
339
|
AgentsPanel .agent-row Button.agent-delete {
|
|
114
340
|
width: 3;
|
|
115
341
|
min-width: 3;
|
|
116
|
-
height:
|
|
117
|
-
margin-left:
|
|
342
|
+
height: 1;
|
|
343
|
+
margin-left: 0;
|
|
118
344
|
padding: 0;
|
|
119
345
|
content-align: center middle;
|
|
120
346
|
}
|
|
121
347
|
|
|
122
348
|
AgentsPanel .agent-row Button.agent-share {
|
|
123
349
|
width: auto;
|
|
124
|
-
min-width:
|
|
125
|
-
height:
|
|
126
|
-
margin-left:
|
|
350
|
+
min-width: 6;
|
|
351
|
+
height: 1;
|
|
352
|
+
margin-left: 0;
|
|
127
353
|
padding: 0 1;
|
|
128
354
|
content-align: center middle;
|
|
129
355
|
}
|
|
@@ -137,25 +363,34 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
137
363
|
}
|
|
138
364
|
|
|
139
365
|
AgentsPanel .terminal-row {
|
|
140
|
-
height:
|
|
141
|
-
min-height: 2;
|
|
366
|
+
height: 1;
|
|
142
367
|
margin: 0;
|
|
143
368
|
}
|
|
144
369
|
|
|
145
370
|
AgentsPanel .terminal-row Button.terminal-switch {
|
|
146
371
|
width: 1fr;
|
|
147
|
-
height:
|
|
372
|
+
height: 1;
|
|
148
373
|
margin: 0;
|
|
149
374
|
padding: 0 1;
|
|
150
375
|
text-align: left;
|
|
151
|
-
content-align: left
|
|
376
|
+
content-align: left middle;
|
|
377
|
+
text-opacity: 60%;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
AgentsPanel .terminal-row Button.terminal-switch:hover {
|
|
381
|
+
text-opacity: 100%;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
AgentsPanel .terminal-row Button.active-terminal {
|
|
385
|
+
text-opacity: 100%;
|
|
386
|
+
text-style: bold;
|
|
152
387
|
}
|
|
153
388
|
|
|
154
389
|
AgentsPanel .terminal-row Button.terminal-close {
|
|
155
390
|
width: 3;
|
|
156
391
|
min-width: 3;
|
|
157
|
-
height:
|
|
158
|
-
margin
|
|
392
|
+
height: 1;
|
|
393
|
+
margin: 0;
|
|
159
394
|
padding: 0;
|
|
160
395
|
content-align: center middle;
|
|
161
396
|
}
|
|
@@ -165,30 +400,58 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
165
400
|
super().__init__()
|
|
166
401
|
self._refresh_lock = asyncio.Lock()
|
|
167
402
|
self._agents: list[dict] = []
|
|
403
|
+
self._active_terminal_id: str | None = None
|
|
168
404
|
self._rendered_fingerprint: str = ""
|
|
405
|
+
self._switch_worker: Optional[Worker] = None
|
|
406
|
+
self._switch_seq: int = 0
|
|
169
407
|
self._refresh_worker: Optional[Worker] = None
|
|
408
|
+
self._render_worker: Optional[Worker] = None
|
|
409
|
+
self._pending_render: bool = False
|
|
410
|
+
self._pending_render_reason: str | None = None
|
|
170
411
|
self._share_worker: Optional[Worker] = None
|
|
171
412
|
self._sync_worker: Optional[Worker] = None
|
|
172
413
|
self._share_agent_id: Optional[str] = None
|
|
173
414
|
self._sync_agent_id: Optional[str] = None
|
|
174
415
|
self._refresh_timer = None
|
|
416
|
+
self._spinner_timer = None
|
|
175
417
|
self._last_refresh_error_at: float | None = None
|
|
418
|
+
self._last_render_started_at: float | None = None
|
|
419
|
+
self._last_render_completed_at: float | None = None
|
|
420
|
+
self._process_snapshot: _ProcessSnapshot | None = None
|
|
176
421
|
|
|
177
422
|
def compose(self) -> ComposeResult:
|
|
178
423
|
with Horizontal(classes="summary"):
|
|
179
|
-
yield Button("
|
|
424
|
+
yield Button("Add Agent", id="create-agent", variant="primary")
|
|
180
425
|
with Vertical(id="agents-list", classes="list"):
|
|
181
|
-
yield Static("No agents yet. Click '
|
|
426
|
+
yield Static("No agents yet. Click 'Add Agent' to add one.")
|
|
427
|
+
|
|
428
|
+
def on_mount(self) -> None:
|
|
429
|
+
btn = self.query_one("#create-agent", Button)
|
|
430
|
+
btn.styles.border = ("round", "black")
|
|
431
|
+
btn.styles.text_align = "center"
|
|
432
|
+
btn.styles.content_align = ("center", "middle")
|
|
182
433
|
|
|
183
434
|
def on_show(self) -> None:
|
|
184
435
|
if self._refresh_timer is None:
|
|
185
436
|
# Refresh frequently, but avoid hammering SQLite/tmux (can cause transient empty UI).
|
|
186
|
-
self._refresh_timer = self.set_interval(
|
|
437
|
+
self._refresh_timer = self.set_interval(
|
|
438
|
+
self.REFRESH_INTERVAL_SECONDS, self._on_refresh_timer
|
|
439
|
+
)
|
|
187
440
|
else:
|
|
188
441
|
try:
|
|
189
442
|
self._refresh_timer.resume()
|
|
190
443
|
except Exception:
|
|
191
444
|
pass
|
|
445
|
+
|
|
446
|
+
if self._spinner_timer is None:
|
|
447
|
+
self._spinner_timer = self.set_interval(
|
|
448
|
+
self.SPINNER_INTERVAL_SECONDS, self._on_spinner_timer
|
|
449
|
+
)
|
|
450
|
+
else:
|
|
451
|
+
try:
|
|
452
|
+
self._spinner_timer.resume()
|
|
453
|
+
except Exception:
|
|
454
|
+
pass
|
|
192
455
|
self.refresh_data()
|
|
193
456
|
|
|
194
457
|
def on_hide(self) -> None:
|
|
@@ -197,10 +460,24 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
197
460
|
self._refresh_timer.pause()
|
|
198
461
|
except Exception:
|
|
199
462
|
pass
|
|
463
|
+
if self._spinner_timer is not None:
|
|
464
|
+
try:
|
|
465
|
+
self._spinner_timer.pause()
|
|
466
|
+
except Exception:
|
|
467
|
+
pass
|
|
200
468
|
|
|
201
469
|
def _on_refresh_timer(self) -> None:
|
|
202
470
|
self.refresh_data()
|
|
203
471
|
|
|
472
|
+
def _on_spinner_timer(self) -> None:
|
|
473
|
+
if not self.display:
|
|
474
|
+
return
|
|
475
|
+
try:
|
|
476
|
+
for btn in self.query("Button.spinner"):
|
|
477
|
+
btn.refresh()
|
|
478
|
+
except Exception:
|
|
479
|
+
pass
|
|
480
|
+
|
|
204
481
|
def refresh_data(self) -> None:
|
|
205
482
|
if not self.display:
|
|
206
483
|
return
|
|
@@ -213,7 +490,177 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
213
490
|
self._collect_agents, thread=True, exit_on_error=False
|
|
214
491
|
)
|
|
215
492
|
|
|
216
|
-
def
|
|
493
|
+
def diagnostics_state(self) -> dict[str, object]:
|
|
494
|
+
"""Small, safe snapshot for watchdog logging (never raises)."""
|
|
495
|
+
state: dict[str, object] = {}
|
|
496
|
+
try:
|
|
497
|
+
state["agents_count"] = int(len(self._agents))
|
|
498
|
+
state["rendered_fingerprint_len"] = int(len(self._rendered_fingerprint))
|
|
499
|
+
state["refresh_worker_state"] = str(
|
|
500
|
+
getattr(getattr(self, "_refresh_worker", None), "state", None)
|
|
501
|
+
)
|
|
502
|
+
state["render_worker_state"] = str(
|
|
503
|
+
getattr(getattr(self, "_render_worker", None), "state", None)
|
|
504
|
+
)
|
|
505
|
+
state["pending_render"] = bool(self._pending_render)
|
|
506
|
+
state["last_refresh_error_at"] = self._last_refresh_error_at
|
|
507
|
+
state["last_render_started_at"] = self._last_render_started_at
|
|
508
|
+
state["last_render_completed_at"] = self._last_render_completed_at
|
|
509
|
+
except Exception:
|
|
510
|
+
pass
|
|
511
|
+
try:
|
|
512
|
+
container = self.query_one("#agents-list", Vertical)
|
|
513
|
+
state["agents_list_children"] = int(len(getattr(container, "children", []) or []))
|
|
514
|
+
except Exception:
|
|
515
|
+
pass
|
|
516
|
+
return state
|
|
517
|
+
|
|
518
|
+
def _ui_blank(self) -> bool:
|
|
519
|
+
try:
|
|
520
|
+
container = self.query_one("#agents-list", Vertical)
|
|
521
|
+
return len(container.children) == 0
|
|
522
|
+
except Exception:
|
|
523
|
+
return False
|
|
524
|
+
|
|
525
|
+
def _schedule_render(self, *, reason: str) -> None:
|
|
526
|
+
if not self.display:
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
if self._render_worker is not None and self._render_worker.state in (
|
|
530
|
+
WorkerState.PENDING,
|
|
531
|
+
WorkerState.RUNNING,
|
|
532
|
+
):
|
|
533
|
+
self._pending_render = True
|
|
534
|
+
self._pending_render_reason = reason
|
|
535
|
+
return
|
|
536
|
+
|
|
537
|
+
self._pending_render = False
|
|
538
|
+
self._pending_render_reason = None
|
|
539
|
+
self._last_render_started_at = time.monotonic()
|
|
540
|
+
self._render_worker = self.run_worker(
|
|
541
|
+
self._render_agents(), group="agents-render", exit_on_error=False
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
def force_render(self, *, reason: str) -> None:
|
|
545
|
+
"""Force a re-render even if the collected data didn't change."""
|
|
546
|
+
# Reset fingerprint so the next refresh doesn't treat the UI as "up to date".
|
|
547
|
+
self._rendered_fingerprint = ""
|
|
548
|
+
if self._agents:
|
|
549
|
+
self._schedule_render(reason=reason)
|
|
550
|
+
|
|
551
|
+
@staticmethod
|
|
552
|
+
def _detect_running_agent(
|
|
553
|
+
pane_pid: int | None, snapshot: _ProcessSnapshot | None
|
|
554
|
+
) -> str | None:
|
|
555
|
+
"""Detect whether a known agent process exists under a tmux pane PID.
|
|
556
|
+
|
|
557
|
+
Uses a cached process snapshot built once per refresh to avoid spawning `ps`
|
|
558
|
+
repeatedly for every terminal row.
|
|
559
|
+
"""
|
|
560
|
+
if not pane_pid or snapshot is None:
|
|
561
|
+
return None
|
|
562
|
+
|
|
563
|
+
children = snapshot.children
|
|
564
|
+
comms = snapshot.comms
|
|
565
|
+
stack = [pane_pid]
|
|
566
|
+
seen: set[int] = set()
|
|
567
|
+
while stack:
|
|
568
|
+
p = stack.pop()
|
|
569
|
+
if p in seen:
|
|
570
|
+
continue
|
|
571
|
+
seen.add(p)
|
|
572
|
+
comm = (comms.get(p) or "").lower()
|
|
573
|
+
for kw in ("claude", "codex", "opencode"):
|
|
574
|
+
if kw in comm:
|
|
575
|
+
return kw
|
|
576
|
+
stack.extend(children.get(p, []))
|
|
577
|
+
return None
|
|
578
|
+
|
|
579
|
+
@staticmethod
|
|
580
|
+
def _normalize_tty(tty: str) -> str:
|
|
581
|
+
s = (tty or "").strip()
|
|
582
|
+
if not s:
|
|
583
|
+
return ""
|
|
584
|
+
if s.startswith("/dev/"):
|
|
585
|
+
s = s[len("/dev/") :]
|
|
586
|
+
return s.strip()
|
|
587
|
+
|
|
588
|
+
def _get_process_snapshot(self, *, pane_ttys: set[str]) -> _ProcessSnapshot | None:
|
|
589
|
+
"""Return a cached process snapshot scoped to the given TTY set.
|
|
590
|
+
|
|
591
|
+
TTL is aligned with the panel refresh interval so the snapshot is at most one refresh stale.
|
|
592
|
+
"""
|
|
593
|
+
ttys = sorted(
|
|
594
|
+
{self._normalize_tty(t) for t in (pane_ttys or set()) if self._normalize_tty(t)}
|
|
595
|
+
)
|
|
596
|
+
if not ttys:
|
|
597
|
+
return None
|
|
598
|
+
tty_key = ",".join(ttys)
|
|
599
|
+
|
|
600
|
+
now = time.monotonic()
|
|
601
|
+
cached = self._process_snapshot
|
|
602
|
+
if (
|
|
603
|
+
cached
|
|
604
|
+
and cached.tty_key == tty_key
|
|
605
|
+
and (now - cached.created_at_monotonic) < float(self.REFRESH_INTERVAL_SECONDS)
|
|
606
|
+
):
|
|
607
|
+
return cached
|
|
608
|
+
|
|
609
|
+
try:
|
|
610
|
+
import subprocess
|
|
611
|
+
|
|
612
|
+
# Prefer TTY-limited `ps` to keep output small. BSD ps supports comma-separated TTY list.
|
|
613
|
+
result = subprocess.run(
|
|
614
|
+
["ps", "-o", "pid=,ppid=,comm=", "-t", tty_key],
|
|
615
|
+
capture_output=True,
|
|
616
|
+
text=True,
|
|
617
|
+
timeout=2,
|
|
618
|
+
)
|
|
619
|
+
stdout = result.stdout or ""
|
|
620
|
+
if result.returncode != 0 or not stdout.strip():
|
|
621
|
+
# Fallback: some environments may not accept `-t` list; fall back to a single
|
|
622
|
+
# snapshot of all processes and filter by TTY.
|
|
623
|
+
result = subprocess.run(
|
|
624
|
+
["ps", "-axo", "pid=,ppid=,tty=,comm="],
|
|
625
|
+
capture_output=True,
|
|
626
|
+
text=True,
|
|
627
|
+
timeout=2,
|
|
628
|
+
)
|
|
629
|
+
stdout = result.stdout or ""
|
|
630
|
+
|
|
631
|
+
allowed = set(ttys)
|
|
632
|
+
filtered_lines: list[str] = []
|
|
633
|
+
for line in stdout.splitlines():
|
|
634
|
+
parts = line.split(None, 3)
|
|
635
|
+
if len(parts) < 4:
|
|
636
|
+
continue
|
|
637
|
+
tty = self._normalize_tty(parts[2])
|
|
638
|
+
if tty and tty in allowed:
|
|
639
|
+
filtered_lines.append(f"{parts[0]} {parts[1]} {parts[3]}")
|
|
640
|
+
stdout = "\n".join(filtered_lines)
|
|
641
|
+
|
|
642
|
+
children: dict[int, list[int]] = {}
|
|
643
|
+
comms: dict[int, str] = {}
|
|
644
|
+
for line in stdout.splitlines():
|
|
645
|
+
parts = line.split(None, 2)
|
|
646
|
+
if len(parts) < 3:
|
|
647
|
+
continue
|
|
648
|
+
try:
|
|
649
|
+
pid, ppid = int(parts[0]), int(parts[1])
|
|
650
|
+
except ValueError:
|
|
651
|
+
continue
|
|
652
|
+
children.setdefault(ppid, []).append(pid)
|
|
653
|
+
comms[pid] = parts[2]
|
|
654
|
+
|
|
655
|
+
snap = _ProcessSnapshot(
|
|
656
|
+
created_at_monotonic=now, tty_key=tty_key, children=children, comms=comms
|
|
657
|
+
)
|
|
658
|
+
self._process_snapshot = snap
|
|
659
|
+
return snap
|
|
660
|
+
except Exception:
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
def _collect_agents(self) -> dict:
|
|
217
664
|
"""Collect agent info with their terminals."""
|
|
218
665
|
agents: list[dict] = []
|
|
219
666
|
|
|
@@ -241,15 +688,41 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
241
688
|
l.terminal_id: l for l in latest_links if getattr(l, "terminal_id", None)
|
|
242
689
|
}
|
|
243
690
|
|
|
691
|
+
tmux_windows_ok = True
|
|
244
692
|
try:
|
|
245
693
|
tmux_windows = tmux_manager.list_inner_windows()
|
|
246
694
|
except Exception as e:
|
|
247
695
|
logger.debug(f"Failed to list tmux windows: {e}")
|
|
696
|
+
tmux_windows_ok = False
|
|
248
697
|
tmux_windows = []
|
|
249
698
|
terminal_to_window = {
|
|
250
699
|
w.terminal_id: w for w in tmux_windows if getattr(w, "terminal_id", None)
|
|
251
700
|
}
|
|
252
701
|
|
|
702
|
+
# If we are running inside the managed tmux dashboard environment, only show terminals
|
|
703
|
+
# that still exist in the inner tmux server. This prevents stale buttons when a user
|
|
704
|
+
# exits a shell directly (tmux window disappears but DB record may still be "active").
|
|
705
|
+
try:
|
|
706
|
+
if tmux_manager.managed_env_enabled() and tmux_windows_ok:
|
|
707
|
+
present = {tid for tid in terminal_to_window.keys() if tid}
|
|
708
|
+
if present:
|
|
709
|
+
active_terminals = [t for t in active_terminals if getattr(t, "id", None) in present]
|
|
710
|
+
else:
|
|
711
|
+
# Inner session exists but no tracked terminals are present.
|
|
712
|
+
active_terminals = []
|
|
713
|
+
except Exception:
|
|
714
|
+
pass
|
|
715
|
+
|
|
716
|
+
pane_ttys: set[str] = set()
|
|
717
|
+
for w in tmux_windows:
|
|
718
|
+
if getattr(w, "pane_pid", None) and getattr(w, "pane_tty", None):
|
|
719
|
+
pane_ttys.add(str(w.pane_tty))
|
|
720
|
+
ps_snapshot = self._get_process_snapshot(pane_ttys=pane_ttys) if pane_ttys else None
|
|
721
|
+
|
|
722
|
+
# Detect currently active terminal
|
|
723
|
+
active_window = next((w for w in tmux_windows if w.active), None)
|
|
724
|
+
active_terminal_id = active_window.terminal_id if active_window else None
|
|
725
|
+
|
|
253
726
|
# Collect all session_ids for title lookup
|
|
254
727
|
session_ids: list[str] = []
|
|
255
728
|
for t in active_terminals:
|
|
@@ -260,8 +733,31 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
260
733
|
window = terminal_to_window.get(t.id)
|
|
261
734
|
if window and getattr(window, "session_id", None):
|
|
262
735
|
session_ids.append(window.session_id)
|
|
736
|
+
continue
|
|
737
|
+
# Fallback: agent record (works even when WindowLink isn't available yet / at all).
|
|
738
|
+
agent_session_id = getattr(t, "session_id", None)
|
|
739
|
+
if agent_session_id:
|
|
740
|
+
session_ids.append(agent_session_id)
|
|
263
741
|
|
|
264
|
-
titles =
|
|
742
|
+
titles: dict[str, str] = {}
|
|
743
|
+
session_agent_id: dict[str, str] = {}
|
|
744
|
+
try:
|
|
745
|
+
uniq_session_ids = sorted({sid for sid in session_ids if sid})
|
|
746
|
+
if uniq_session_ids:
|
|
747
|
+
sessions = db.get_sessions_by_ids(uniq_session_ids)
|
|
748
|
+
for s in sessions:
|
|
749
|
+
sid = getattr(s, "id", None)
|
|
750
|
+
if not sid:
|
|
751
|
+
continue
|
|
752
|
+
title = (getattr(s, "session_title", "") or "").strip()
|
|
753
|
+
if title:
|
|
754
|
+
titles[sid] = title
|
|
755
|
+
agent_id = getattr(s, "agent_id", None)
|
|
756
|
+
if agent_id:
|
|
757
|
+
session_agent_id[sid] = agent_id
|
|
758
|
+
except Exception:
|
|
759
|
+
titles = {}
|
|
760
|
+
session_agent_id = {}
|
|
265
761
|
|
|
266
762
|
# Map agent_info.id -> list of terminals
|
|
267
763
|
agent_to_terminals: dict[str, list[dict]] = {}
|
|
@@ -284,12 +780,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
284
780
|
if not agent_info_id:
|
|
285
781
|
window = terminal_to_window.get(t.id)
|
|
286
782
|
if window and getattr(window, "session_id", None):
|
|
287
|
-
|
|
288
|
-
session = db.get_session_by_id(window.session_id)
|
|
289
|
-
except Exception:
|
|
290
|
-
session = None
|
|
291
|
-
if session:
|
|
292
|
-
agent_info_id = session.agent_id
|
|
783
|
+
agent_info_id = session_agent_id.get(window.session_id) or None
|
|
293
784
|
|
|
294
785
|
if agent_info_id:
|
|
295
786
|
agent_to_terminals.setdefault(agent_info_id, [])
|
|
@@ -302,25 +793,32 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
302
793
|
else (
|
|
303
794
|
window.session_id
|
|
304
795
|
if window and getattr(window, "session_id", None)
|
|
305
|
-
else None
|
|
796
|
+
else getattr(t, "session_id", None)
|
|
306
797
|
)
|
|
307
798
|
)
|
|
308
799
|
title = titles.get(session_id, "") if session_id else ""
|
|
309
800
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
link.provider
|
|
316
|
-
if link and getattr(link, "provider", None)
|
|
317
|
-
else (t.provider or "")
|
|
318
|
-
),
|
|
319
|
-
"session_type": t.session_type or "",
|
|
320
|
-
"title": title,
|
|
321
|
-
"cwd": t.cwd or "",
|
|
322
|
-
}
|
|
801
|
+
pane_cmd = window.pane_current_command if window else None
|
|
802
|
+
provider = (
|
|
803
|
+
link.provider
|
|
804
|
+
if link and getattr(link, "provider", None)
|
|
805
|
+
else (t.provider or "")
|
|
323
806
|
)
|
|
807
|
+
pane_pid = window.pane_pid if window else None
|
|
808
|
+
running_agent = self._detect_running_agent(pane_pid, ps_snapshot)
|
|
809
|
+
|
|
810
|
+
entry = {
|
|
811
|
+
"terminal_id": t.id,
|
|
812
|
+
"window_id": window.window_id if window else None,
|
|
813
|
+
"session_id": session_id,
|
|
814
|
+
"provider": provider,
|
|
815
|
+
"session_type": t.session_type or "",
|
|
816
|
+
"title": title,
|
|
817
|
+
"cwd": t.cwd or "",
|
|
818
|
+
"running_agent": running_agent,
|
|
819
|
+
"pane_current_command": pane_cmd,
|
|
820
|
+
}
|
|
821
|
+
agent_to_terminals[agent_info_id].append(entry)
|
|
324
822
|
|
|
325
823
|
for info in agent_infos:
|
|
326
824
|
terminals = agent_to_terminals.get(info.id, [])
|
|
@@ -335,7 +833,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
335
833
|
}
|
|
336
834
|
)
|
|
337
835
|
|
|
338
|
-
return agents
|
|
836
|
+
return {"agents": agents, "active_terminal_id": active_terminal_id}
|
|
339
837
|
|
|
340
838
|
@staticmethod
|
|
341
839
|
def _fingerprint(agents: list[dict]) -> str:
|
|
@@ -346,6 +844,42 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
346
844
|
return ""
|
|
347
845
|
|
|
348
846
|
def on_worker_state_changed(self, event: Worker.StateChanged) -> None:
|
|
847
|
+
# Handle switch worker
|
|
848
|
+
if self._switch_worker is not None and event.worker is self._switch_worker:
|
|
849
|
+
if event.state == WorkerState.SUCCESS:
|
|
850
|
+
result = self._switch_worker.result or {}
|
|
851
|
+
if isinstance(result, dict):
|
|
852
|
+
seq = int(result.get("seq", 0) or 0)
|
|
853
|
+
if seq != self._switch_seq:
|
|
854
|
+
return
|
|
855
|
+
terminal_id = str(result.get("terminal_id", "") or "").strip() or None
|
|
856
|
+
ok = bool(result.get("ok", False))
|
|
857
|
+
if ok and terminal_id:
|
|
858
|
+
self._active_terminal_id = terminal_id
|
|
859
|
+
self._update_active_terminal_ui(terminal_id)
|
|
860
|
+
# Prevent the next refresh from forcing a full re-render just due to
|
|
861
|
+
# active-terminal changes.
|
|
862
|
+
self._rendered_fingerprint = (
|
|
863
|
+
self._fingerprint(self._agents) + f"|active:{self._active_terminal_id}"
|
|
864
|
+
)
|
|
865
|
+
else:
|
|
866
|
+
msg = str(result.get("error", "") or "Failed to switch").strip()
|
|
867
|
+
self.app.notify(msg, title="Agent", severity="error")
|
|
868
|
+
self.refresh_data()
|
|
869
|
+
elif event.state == WorkerState.ERROR:
|
|
870
|
+
err = self._switch_worker.error
|
|
871
|
+
msg = "Failed to switch"
|
|
872
|
+
if isinstance(err, BaseException):
|
|
873
|
+
msg = f"Failed to switch: {err}"
|
|
874
|
+
elif err:
|
|
875
|
+
msg = f"Failed to switch: {err}"
|
|
876
|
+
self.app.notify(msg, title="Agent", severity="error")
|
|
877
|
+
self.refresh_data()
|
|
878
|
+
|
|
879
|
+
if event.state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
880
|
+
self._switch_worker = None
|
|
881
|
+
return
|
|
882
|
+
|
|
349
883
|
# Handle refresh worker
|
|
350
884
|
if self._refresh_worker is not None and event.worker is self._refresh_worker:
|
|
351
885
|
if event.state == WorkerState.ERROR:
|
|
@@ -362,20 +896,70 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
362
896
|
logger.warning(f"Agents refresh failed: {err}")
|
|
363
897
|
return
|
|
364
898
|
elif event.state == WorkerState.SUCCESS:
|
|
365
|
-
|
|
899
|
+
prev_agents = self._agents
|
|
900
|
+
prev_active = self._active_terminal_id
|
|
901
|
+
payload = self._refresh_worker.result or {}
|
|
902
|
+
if isinstance(payload, dict) and "agents" in payload:
|
|
903
|
+
self._agents = payload.get("agents") or []
|
|
904
|
+
self._active_terminal_id = payload.get("active_terminal_id") or None
|
|
905
|
+
else:
|
|
906
|
+
self._agents = payload or []
|
|
366
907
|
self._last_refresh_error_at = None
|
|
367
908
|
else:
|
|
368
909
|
return
|
|
369
|
-
|
|
370
|
-
|
|
910
|
+
|
|
911
|
+
# Fast path: only the active terminal changed; update button classes in-place
|
|
912
|
+
# to avoid full re-render flicker / input lag.
|
|
913
|
+
try:
|
|
914
|
+
prev_agents_fp = self._fingerprint(prev_agents)
|
|
915
|
+
new_agents_fp = self._fingerprint(self._agents)
|
|
916
|
+
if (
|
|
917
|
+
prev_agents_fp == new_agents_fp
|
|
918
|
+
and prev_active != self._active_terminal_id
|
|
919
|
+
and not self._ui_blank()
|
|
920
|
+
):
|
|
921
|
+
self._update_active_terminal_ui(self._active_terminal_id)
|
|
922
|
+
self._rendered_fingerprint = new_agents_fp + f"|active:{self._active_terminal_id}"
|
|
923
|
+
return
|
|
924
|
+
except Exception:
|
|
925
|
+
new_agents_fp = self._fingerprint(self._agents)
|
|
926
|
+
|
|
927
|
+
fp = new_agents_fp + f"|active:{self._active_terminal_id}"
|
|
928
|
+
# Important: the UI can become blank due to cancellation or transient Textual issues.
|
|
929
|
+
# If data didn't change but the widget tree is empty, force a re-render.
|
|
930
|
+
should_render = (fp != self._rendered_fingerprint) or (
|
|
931
|
+
self._agents and self._ui_blank()
|
|
932
|
+
)
|
|
933
|
+
if not should_render:
|
|
371
934
|
return # nothing changed – skip re-render to avoid flicker
|
|
372
935
|
self._rendered_fingerprint = fp
|
|
373
|
-
self.
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
936
|
+
self._schedule_render(reason="refresh")
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
# Handle render worker
|
|
940
|
+
if self._render_worker is not None and event.worker is self._render_worker:
|
|
941
|
+
if event.state == WorkerState.ERROR:
|
|
942
|
+
err = self._render_worker.error
|
|
943
|
+
if isinstance(err, BaseException):
|
|
944
|
+
logger.warning(
|
|
945
|
+
"Agents render failed",
|
|
946
|
+
exc_info=(type(err), err, err.__traceback__),
|
|
947
|
+
)
|
|
948
|
+
else:
|
|
949
|
+
logger.warning(f"Agents render failed: {err}")
|
|
950
|
+
# Force next refresh to re-render even if data is unchanged.
|
|
951
|
+
self._rendered_fingerprint = ""
|
|
952
|
+
elif event.state == WorkerState.SUCCESS:
|
|
953
|
+
self._last_render_completed_at = time.monotonic()
|
|
954
|
+
elif event.state == WorkerState.CANCELLED:
|
|
955
|
+
self._rendered_fingerprint = ""
|
|
956
|
+
|
|
957
|
+
if event.state in {WorkerState.SUCCESS, WorkerState.ERROR, WorkerState.CANCELLED}:
|
|
958
|
+
self._last_render_completed_at = time.monotonic()
|
|
959
|
+
self._render_worker = None
|
|
960
|
+
if self._pending_render:
|
|
961
|
+
reason = self._pending_render_reason or "pending"
|
|
962
|
+
self._schedule_render(reason=reason)
|
|
379
963
|
return
|
|
380
964
|
|
|
381
965
|
# Handle share worker
|
|
@@ -390,112 +974,20 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
390
974
|
async with self._refresh_lock:
|
|
391
975
|
try:
|
|
392
976
|
container = self.query_one("#agents-list", Vertical)
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if not self._agents:
|
|
396
|
-
await container.mount(Static("No agents yet. Click 'Create Agent' to add one."))
|
|
397
|
-
return
|
|
398
|
-
|
|
399
|
-
for agent in self._agents:
|
|
400
|
-
safe_id = self._safe_id(agent["id"])
|
|
401
|
-
|
|
402
|
-
# Agent row with name, create button, and delete button
|
|
403
|
-
row = Horizontal(classes="agent-row")
|
|
404
|
-
await container.mount(row)
|
|
405
|
-
|
|
406
|
-
# Agent name button
|
|
407
|
-
name_label = Text(agent["name"], style="bold")
|
|
408
|
-
terminal_count = len(agent["terminals"])
|
|
409
|
-
if terminal_count > 0:
|
|
410
|
-
name_label.append(f" ({terminal_count})", style="dim")
|
|
411
|
-
|
|
412
|
-
agent_btn = AgentNameButton(
|
|
413
|
-
name_label,
|
|
414
|
-
id=f"agent-{safe_id}",
|
|
415
|
-
name=agent["id"],
|
|
416
|
-
classes="agent-name",
|
|
417
|
-
)
|
|
418
|
-
if agent.get("description"):
|
|
419
|
-
agent_btn.tooltip = agent["description"]
|
|
420
|
-
await row.mount(agent_btn)
|
|
421
|
-
|
|
422
|
-
# Share or Sync button (Sync if agent already has a share_url)
|
|
423
|
-
if agent.get("share_url"):
|
|
424
|
-
await row.mount(
|
|
425
|
-
Button(
|
|
426
|
-
"Sync",
|
|
427
|
-
id=f"sync-{safe_id}",
|
|
428
|
-
name=agent["id"],
|
|
429
|
-
classes="agent-share",
|
|
430
|
-
)
|
|
431
|
-
)
|
|
432
|
-
await row.mount(
|
|
433
|
-
Button(
|
|
434
|
-
"Link",
|
|
435
|
-
id=f"link-{safe_id}",
|
|
436
|
-
name=agent["id"],
|
|
437
|
-
classes="agent-share",
|
|
438
|
-
)
|
|
439
|
-
)
|
|
440
|
-
else:
|
|
441
|
-
await row.mount(
|
|
442
|
-
Button(
|
|
443
|
-
"Share",
|
|
444
|
-
id=f"share-{safe_id}",
|
|
445
|
-
name=agent["id"],
|
|
446
|
-
classes="agent-share",
|
|
447
|
-
)
|
|
448
|
-
)
|
|
449
|
-
|
|
450
|
-
# Create terminal button
|
|
451
|
-
await row.mount(
|
|
452
|
-
Button(
|
|
453
|
-
"+ Term",
|
|
454
|
-
id=f"create-term-{safe_id}",
|
|
455
|
-
name=agent["id"],
|
|
456
|
-
classes="agent-create",
|
|
457
|
-
)
|
|
458
|
-
)
|
|
977
|
+
with self.app.batch_update():
|
|
978
|
+
await container.remove_children()
|
|
459
979
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
"✕",
|
|
464
|
-
id=f"delete-{safe_id}",
|
|
465
|
-
name=agent["id"],
|
|
466
|
-
variant="error",
|
|
467
|
-
classes="agent-delete",
|
|
980
|
+
if not self._agents:
|
|
981
|
+
await container.mount(
|
|
982
|
+
Static("No agents yet. Click 'Add Agent' to add one.")
|
|
468
983
|
)
|
|
469
|
-
|
|
984
|
+
return
|
|
470
985
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
for term in agent["terminals"]:
|
|
477
|
-
term_safe_id = self._safe_id(term["terminal_id"])
|
|
478
|
-
term_row = Horizontal(classes="terminal-row")
|
|
479
|
-
await term_list.mount(term_row)
|
|
480
|
-
|
|
481
|
-
label = self._make_terminal_label(term)
|
|
482
|
-
await term_row.mount(
|
|
483
|
-
Button(
|
|
484
|
-
label,
|
|
485
|
-
id=f"switch-{term_safe_id}",
|
|
486
|
-
name=term["terminal_id"],
|
|
487
|
-
classes="terminal-switch",
|
|
488
|
-
)
|
|
489
|
-
)
|
|
490
|
-
await term_row.mount(
|
|
491
|
-
Button(
|
|
492
|
-
"✕",
|
|
493
|
-
id=f"close-{term_safe_id}",
|
|
494
|
-
name=term["terminal_id"],
|
|
495
|
-
variant="error",
|
|
496
|
-
classes="terminal-close",
|
|
497
|
-
)
|
|
498
|
-
)
|
|
986
|
+
blocks = [
|
|
987
|
+
_AgentBlockWidget(agent=agent, active_terminal_id=self._active_terminal_id)
|
|
988
|
+
for agent in self._agents
|
|
989
|
+
]
|
|
990
|
+
await container.mount_all(blocks)
|
|
499
991
|
except Exception:
|
|
500
992
|
logger.exception("Failed to render agents list")
|
|
501
993
|
try:
|
|
@@ -507,35 +999,11 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
507
999
|
except Exception:
|
|
508
1000
|
pass
|
|
509
1001
|
return
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
title = term.get("title", "")
|
|
516
|
-
|
|
517
|
-
label = Text(no_wrap=True, overflow="ellipsis")
|
|
518
|
-
|
|
519
|
-
# First line: title or provider
|
|
520
|
-
if title:
|
|
521
|
-
label.append(title)
|
|
522
|
-
elif provider:
|
|
523
|
-
label.append(provider.capitalize())
|
|
524
|
-
else:
|
|
525
|
-
label.append("Terminal")
|
|
526
|
-
|
|
527
|
-
label.append("\n")
|
|
528
|
-
|
|
529
|
-
# Second line: [provider] session_id
|
|
530
|
-
if provider:
|
|
531
|
-
detail = f"[{provider.capitalize()}]"
|
|
532
|
-
else:
|
|
533
|
-
detail = ""
|
|
534
|
-
if session_id:
|
|
535
|
-
detail = f"{detail} #{self._short_id(session_id)}"
|
|
536
|
-
|
|
537
|
-
label.append(detail, style="dim")
|
|
538
|
-
return label
|
|
1002
|
+
except asyncio.CancelledError:
|
|
1003
|
+
# Best-effort: avoid leaving the UI in a permanently blank state.
|
|
1004
|
+
logger.warning("Agents render cancelled")
|
|
1005
|
+
self._rendered_fingerprint = ""
|
|
1006
|
+
return
|
|
539
1007
|
|
|
540
1008
|
@staticmethod
|
|
541
1009
|
def _short_id(val: str | None) -> str:
|
|
@@ -583,6 +1051,21 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
583
1051
|
pass
|
|
584
1052
|
return None
|
|
585
1053
|
|
|
1054
|
+
def _update_active_terminal_ui(self, terminal_id: str | None) -> None:
|
|
1055
|
+
"""Update just the active-terminal button classes (no full re-render)."""
|
|
1056
|
+
try:
|
|
1057
|
+
for btn in self.query("Button.terminal-switch"):
|
|
1058
|
+
btn_id = btn.id or ""
|
|
1059
|
+
if not btn_id.startswith("switch-"):
|
|
1060
|
+
continue
|
|
1061
|
+
is_active = bool(terminal_id and (btn.name == terminal_id))
|
|
1062
|
+
if is_active:
|
|
1063
|
+
btn.add_class("active-terminal")
|
|
1064
|
+
else:
|
|
1065
|
+
btn.remove_class("active-terminal")
|
|
1066
|
+
except Exception:
|
|
1067
|
+
return
|
|
1068
|
+
|
|
586
1069
|
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
587
1070
|
btn_id = event.button.id or ""
|
|
588
1071
|
|
|
@@ -621,7 +1104,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
621
1104
|
|
|
622
1105
|
if btn_id.startswith("switch-"):
|
|
623
1106
|
terminal_id = event.button.name or ""
|
|
624
|
-
|
|
1107
|
+
self._request_switch_to_terminal(terminal_id)
|
|
625
1108
|
return
|
|
626
1109
|
|
|
627
1110
|
if btn_id.startswith("close-"):
|
|
@@ -734,6 +1217,16 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
734
1217
|
"""Create a Claude terminal associated with an agent."""
|
|
735
1218
|
terminal_id = tmux_manager.new_terminal_id()
|
|
736
1219
|
context_id = tmux_manager.new_context_id("cc")
|
|
1220
|
+
logger.info(
|
|
1221
|
+
"Create terminal requested: provider=claude terminal_id=%s agent_id=%s workspace=%s "
|
|
1222
|
+
"no_track=%s skip_permissions=%s context_id=%s",
|
|
1223
|
+
terminal_id,
|
|
1224
|
+
agent_id,
|
|
1225
|
+
workspace,
|
|
1226
|
+
no_track,
|
|
1227
|
+
skip_permissions,
|
|
1228
|
+
context_id,
|
|
1229
|
+
)
|
|
737
1230
|
|
|
738
1231
|
# Prepare CODEX_HOME so user can run codex in this terminal
|
|
739
1232
|
try:
|
|
@@ -777,6 +1270,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
777
1270
|
)
|
|
778
1271
|
|
|
779
1272
|
if created:
|
|
1273
|
+
logger.info(
|
|
1274
|
+
"Create terminal success: provider=claude terminal_id=%s window_id=%s",
|
|
1275
|
+
terminal_id,
|
|
1276
|
+
created.window_id,
|
|
1277
|
+
)
|
|
1278
|
+
if not tmux_manager.focus_right_pane():
|
|
1279
|
+
logger.warning("Create terminal: focus_right_pane failed (provider=claude)")
|
|
780
1280
|
# Store agent association in database with agent_info_id in source
|
|
781
1281
|
try:
|
|
782
1282
|
from ...db import get_database
|
|
@@ -794,6 +1294,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
794
1294
|
except Exception:
|
|
795
1295
|
pass
|
|
796
1296
|
else:
|
|
1297
|
+
logger.warning("Create terminal failed: provider=claude terminal_id=%s", terminal_id)
|
|
797
1298
|
self.app.notify("Failed to create terminal", title="Agent", severity="error")
|
|
798
1299
|
|
|
799
1300
|
async def _create_codex_terminal(self, workspace: str, no_track: bool, agent_id: str) -> None:
|
|
@@ -821,6 +1322,15 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
821
1322
|
|
|
822
1323
|
terminal_id = tmux_manager.new_terminal_id()
|
|
823
1324
|
context_id = tmux_manager.new_context_id("cx")
|
|
1325
|
+
logger.info(
|
|
1326
|
+
"Create terminal requested: provider=codex terminal_id=%s agent_id=%s workspace=%s "
|
|
1327
|
+
"no_track=%s context_id=%s",
|
|
1328
|
+
terminal_id,
|
|
1329
|
+
agent_id,
|
|
1330
|
+
workspace,
|
|
1331
|
+
no_track,
|
|
1332
|
+
context_id,
|
|
1333
|
+
)
|
|
824
1334
|
|
|
825
1335
|
try:
|
|
826
1336
|
from ...codex_home import prepare_codex_home
|
|
@@ -870,13 +1380,30 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
870
1380
|
no_track=no_track,
|
|
871
1381
|
)
|
|
872
1382
|
|
|
873
|
-
if
|
|
1383
|
+
if created:
|
|
1384
|
+
logger.info(
|
|
1385
|
+
"Create terminal success: provider=codex terminal_id=%s window_id=%s",
|
|
1386
|
+
terminal_id,
|
|
1387
|
+
created.window_id,
|
|
1388
|
+
)
|
|
1389
|
+
if not tmux_manager.focus_right_pane():
|
|
1390
|
+
logger.warning("Create terminal: focus_right_pane failed (provider=codex)")
|
|
1391
|
+
else:
|
|
1392
|
+
logger.warning("Create terminal failed: provider=codex terminal_id=%s", terminal_id)
|
|
874
1393
|
self.app.notify("Failed to create terminal", title="Agent", severity="error")
|
|
875
1394
|
|
|
876
1395
|
async def _create_opencode_terminal(self, workspace: str, agent_id: str) -> None:
|
|
877
1396
|
"""Create an Opencode terminal associated with an agent."""
|
|
878
1397
|
terminal_id = tmux_manager.new_terminal_id()
|
|
879
1398
|
context_id = tmux_manager.new_context_id("oc")
|
|
1399
|
+
logger.info(
|
|
1400
|
+
"Create terminal requested: provider=opencode terminal_id=%s agent_id=%s workspace=%s "
|
|
1401
|
+
"context_id=%s",
|
|
1402
|
+
terminal_id,
|
|
1403
|
+
agent_id,
|
|
1404
|
+
workspace,
|
|
1405
|
+
context_id,
|
|
1406
|
+
)
|
|
880
1407
|
|
|
881
1408
|
# Prepare CODEX_HOME so user can run codex in this terminal
|
|
882
1409
|
try:
|
|
@@ -913,6 +1440,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
913
1440
|
)
|
|
914
1441
|
|
|
915
1442
|
if created:
|
|
1443
|
+
logger.info(
|
|
1444
|
+
"Create terminal success: provider=opencode terminal_id=%s window_id=%s",
|
|
1445
|
+
terminal_id,
|
|
1446
|
+
created.window_id,
|
|
1447
|
+
)
|
|
1448
|
+
if not tmux_manager.focus_right_pane():
|
|
1449
|
+
logger.warning("Create terminal: focus_right_pane failed (provider=opencode)")
|
|
916
1450
|
# Store agent association in database
|
|
917
1451
|
try:
|
|
918
1452
|
from ...db import get_database
|
|
@@ -930,12 +1464,20 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
930
1464
|
except Exception:
|
|
931
1465
|
pass
|
|
932
1466
|
else:
|
|
1467
|
+
logger.warning("Create terminal failed: provider=opencode terminal_id=%s", terminal_id)
|
|
933
1468
|
self.app.notify("Failed to create terminal", title="Agent", severity="error")
|
|
934
1469
|
|
|
935
1470
|
async def _create_zsh_terminal(self, workspace: str, agent_id: str) -> None:
|
|
936
1471
|
"""Create a zsh terminal associated with an agent."""
|
|
937
1472
|
terminal_id = tmux_manager.new_terminal_id()
|
|
938
1473
|
context_id = tmux_manager.new_context_id("zsh")
|
|
1474
|
+
logger.info(
|
|
1475
|
+
"Create terminal requested: provider=zsh terminal_id=%s agent_id=%s workspace=%s context_id=%s",
|
|
1476
|
+
terminal_id,
|
|
1477
|
+
agent_id,
|
|
1478
|
+
workspace,
|
|
1479
|
+
context_id,
|
|
1480
|
+
)
|
|
939
1481
|
|
|
940
1482
|
# Prepare CODEX_HOME so user can run codex in this terminal
|
|
941
1483
|
try:
|
|
@@ -970,6 +1512,13 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
970
1512
|
)
|
|
971
1513
|
|
|
972
1514
|
if created:
|
|
1515
|
+
logger.info(
|
|
1516
|
+
"Create terminal success: provider=zsh terminal_id=%s window_id=%s",
|
|
1517
|
+
terminal_id,
|
|
1518
|
+
created.window_id,
|
|
1519
|
+
)
|
|
1520
|
+
if not tmux_manager.focus_right_pane():
|
|
1521
|
+
logger.warning("Create terminal: focus_right_pane failed (provider=zsh)")
|
|
973
1522
|
# Store agent association in database
|
|
974
1523
|
try:
|
|
975
1524
|
from ...db import get_database
|
|
@@ -987,6 +1536,7 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
987
1536
|
except Exception:
|
|
988
1537
|
pass
|
|
989
1538
|
else:
|
|
1539
|
+
logger.warning("Create terminal failed: provider=zsh terminal_id=%s", terminal_id)
|
|
990
1540
|
self.app.notify("Failed to create terminal", title="Agent", severity="error")
|
|
991
1541
|
|
|
992
1542
|
def _install_claude_hooks(self, workspace: str) -> None:
|
|
@@ -1040,20 +1590,87 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1040
1590
|
except Exception as e:
|
|
1041
1591
|
self.app.notify(f"Failed: {e}", title="Agent", severity="error")
|
|
1042
1592
|
|
|
1043
|
-
|
|
1593
|
+
def _request_switch_to_terminal(self, terminal_id: str) -> None:
|
|
1594
|
+
"""Switch terminals without blocking the UI thread."""
|
|
1595
|
+
terminal_id = (terminal_id or "").strip()
|
|
1044
1596
|
if not terminal_id:
|
|
1045
1597
|
return
|
|
1046
1598
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1599
|
+
self._active_terminal_id = terminal_id
|
|
1600
|
+
self._update_active_terminal_ui(terminal_id)
|
|
1601
|
+
self._rendered_fingerprint = (
|
|
1602
|
+
self._fingerprint(self._agents) + f"|active:{self._active_terminal_id}"
|
|
1603
|
+
)
|
|
1604
|
+
|
|
1605
|
+
self._switch_seq += 1
|
|
1606
|
+
seq = self._switch_seq
|
|
1607
|
+
|
|
1608
|
+
if self._switch_worker is not None and self._switch_worker.state in (
|
|
1609
|
+
WorkerState.PENDING,
|
|
1610
|
+
WorkerState.RUNNING,
|
|
1611
|
+
):
|
|
1612
|
+
try:
|
|
1613
|
+
self._switch_worker.cancel()
|
|
1614
|
+
except Exception:
|
|
1615
|
+
pass
|
|
1616
|
+
|
|
1617
|
+
agents_snapshot = self._agents
|
|
1618
|
+
|
|
1619
|
+
def work() -> dict:
|
|
1620
|
+
# Prefer cached window_id to avoid a blocking tmux scan on every click.
|
|
1621
|
+
window_id = None
|
|
1622
|
+
try:
|
|
1623
|
+
for agent in agents_snapshot:
|
|
1624
|
+
for term in agent.get("terminals") or []:
|
|
1625
|
+
if term.get("terminal_id") == terminal_id:
|
|
1626
|
+
window_id = (term.get("window_id") or "").strip() or None
|
|
1627
|
+
break
|
|
1628
|
+
if window_id:
|
|
1629
|
+
break
|
|
1630
|
+
except Exception:
|
|
1631
|
+
window_id = None
|
|
1632
|
+
|
|
1633
|
+
if not window_id:
|
|
1634
|
+
# Fall back to querying tmux (best-effort).
|
|
1635
|
+
window_id = self._find_window(terminal_id)
|
|
1636
|
+
|
|
1637
|
+
if not window_id:
|
|
1638
|
+
# If the terminal was closed from within the shell (e.g. `exit`), the DB record can
|
|
1639
|
+
# remain "active" briefly. Trigger a refresh so the stale button disappears.
|
|
1640
|
+
try:
|
|
1641
|
+
from ...db import get_database
|
|
1642
|
+
|
|
1643
|
+
db = get_database(read_only=False)
|
|
1644
|
+
db.update_agent(terminal_id, status="inactive")
|
|
1645
|
+
except Exception:
|
|
1646
|
+
pass
|
|
1647
|
+
return {
|
|
1648
|
+
"seq": seq,
|
|
1649
|
+
"terminal_id": terminal_id,
|
|
1650
|
+
"ok": False,
|
|
1651
|
+
"error": "Window not found",
|
|
1652
|
+
}
|
|
1051
1653
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1654
|
+
if not tmux_manager.select_inner_window(window_id):
|
|
1655
|
+
return {
|
|
1656
|
+
"seq": seq,
|
|
1657
|
+
"terminal_id": terminal_id,
|
|
1658
|
+
"window_id": window_id,
|
|
1659
|
+
"ok": False,
|
|
1660
|
+
"error": "Failed to switch",
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
# Prefer showing the terminal after switching (best-effort).
|
|
1664
|
+
if not tmux_manager.focus_right_pane():
|
|
1665
|
+
logger.warning(
|
|
1666
|
+
"Switch terminal: focus_right_pane failed (terminal_id=%s window_id=%s)",
|
|
1667
|
+
terminal_id,
|
|
1668
|
+
window_id,
|
|
1669
|
+
)
|
|
1054
1670
|
tmux_manager.clear_attention(window_id)
|
|
1055
|
-
|
|
1056
|
-
|
|
1671
|
+
return {"seq": seq, "terminal_id": terminal_id, "window_id": window_id, "ok": True}
|
|
1672
|
+
|
|
1673
|
+
self._switch_worker = self.run_worker(work, thread=True, exit_on_error=False)
|
|
1057
1674
|
|
|
1058
1675
|
async def _close_terminal(self, terminal_id: str) -> None:
|
|
1059
1676
|
if not terminal_id:
|
|
@@ -1312,6 +1929,4 @@ class AgentsPanel(Container, can_focus=True):
|
|
|
1312
1929
|
if copied:
|
|
1313
1930
|
self.app.notify("Share link copied to clipboard", title="Link", timeout=3)
|
|
1314
1931
|
else:
|
|
1315
|
-
self.app.notify(
|
|
1316
|
-
f"Failed to copy. Link: {share_url}", title="Link", severity="warning"
|
|
1317
|
-
)
|
|
1932
|
+
self.app.notify(f"Failed to copy. Link: {share_url}", title="Link", severity="warning")
|