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